diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme
index b465ab3b9..7aeef5267 100644
--- a/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme
@@ -491,9 +491,9 @@
buildForAnalyzing = "YES">
@@ -796,9 +796,9 @@
skipped = "NO">
diff --git a/Package.resolved b/Package.resolved
index 431dd1cd7..18584326a 100644
--- a/Package.resolved
+++ b/Package.resolved
@@ -63,6 +63,24 @@
"version" : "3.0.0"
}
},
+ {
+ "identity" : "swift-clocks",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-clocks.git",
+ "state" : {
+ "revision" : "b9b24b69e2adda099a1fa381cda1eeec272d5b53",
+ "version" : "1.0.5"
+ }
+ },
+ {
+ "identity" : "swift-concurrency-extras",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-concurrency-extras",
+ "state" : {
+ "revision" : "163409ef7dae9d960b87f34b51587b6609a76c1f",
+ "version" : "1.3.0"
+ }
+ },
{
"identity" : "swifter",
"kind" : "remoteSourceControl",
@@ -89,6 +107,15 @@
"revision" : "5de0a610a7927b638a5fd463a53032c9934a2c3b",
"version" : "3.0.0"
}
+ },
+ {
+ "identity" : "xctest-dynamic-overlay",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
+ "state" : {
+ "revision" : "a3f634d1a409c7979cabc0a71b3f26ffa9fc8af1",
+ "version" : "1.4.3"
+ }
}
],
"version" : 2
diff --git a/Package.swift b/Package.swift
index ded7e6349..479b5c002 100644
--- a/Package.swift
+++ b/Package.swift
@@ -42,7 +42,7 @@ let package = Package(
.library(name: "PixelKitTestingUtilities", targets: ["PixelKitTestingUtilities"]),
.library(name: "SpecialErrorPages", targets: ["SpecialErrorPages"]),
.library(name: "DuckPlayer", targets: ["DuckPlayer"]),
- .library(name: "PhishingDetection", targets: ["PhishingDetection"]),
+ .library(name: "MaliciousSiteProtection", targets: ["MaliciousSiteProtection"]),
.library(name: "Onboarding", targets: ["Onboarding"]),
.library(name: "BrokenSitePrompt", targets: ["BrokenSitePrompt"]),
.library(name: "PageRefreshMonitor", targets: ["PageRefreshMonitor"]),
@@ -58,7 +58,8 @@ let package = Package(
.package(url: "https://github.com/duckduckgo/privacy-dashboard", exact: "7.2.1"),
.package(url: "https://github.com/httpswift/swifter.git", exact: "1.5.0"),
.package(url: "https://github.com/duckduckgo/bloom_cpp.git", exact: "3.0.0"),
- .package(url: "https://github.com/1024jp/GzipSwift.git", exact: "6.0.1")
+ .package(url: "https://github.com/1024jp/GzipSwift.git", exact: "6.0.1"),
+ .package(url: "https://github.com/pointfreeco/swift-clocks.git", exact: "1.0.5"),
],
targets: [
.target(
@@ -250,6 +251,7 @@ let package = Package(
"ContentBlocking",
"Persistence",
"BrowserServicesKit",
+ "MaliciousSiteProtection",
.product(name: "PrivacyDashboardResources", package: "privacy-dashboard")
],
path: "Sources/PrivacyDashboard",
@@ -391,7 +393,8 @@ let package = Package(
dependencies: [
"Common",
"UserScript",
- "BrowserServicesKit"
+ "BrowserServicesKit",
+ "MaliciousSiteProtection",
],
swiftSettings: [
.define("DEBUG", .when(configuration: .debug))
@@ -408,9 +411,11 @@ let package = Package(
]
),
.target(
- name: "PhishingDetection",
+ name: "MaliciousSiteProtection",
dependencies: [
- "Common"
+ "Common",
+ "Networking",
+ "PixelKit",
],
swiftSettings: [
.define("DEBUG", .when(configuration: .debug))
@@ -655,19 +660,21 @@ let package = Package(
.testTarget(
name: "DuckPlayerTests",
dependencies: [
- "DuckPlayer"
+ "DuckPlayer",
+ "BrowserServicesKitTestsUtils",
]
),
.testTarget(
- name: "PhishingDetectionTests",
+ name: "MaliciousSiteProtectionTests",
dependencies: [
- "PhishingDetection",
- "PixelKit"
+ "TestUtils",
+ "MaliciousSiteProtection",
+ .product(name: "Clocks", package: "swift-clocks"),
],
resources: [
- .copy("Resources/hashPrefixes.json"),
- .copy("Resources/filterSet.json")
+ .copy("Resources/phishingHashPrefixes.json"),
+ .copy("Resources/phishingFilterSet.json"),
]
),
.testTarget(
diff --git a/Sources/BrowserServicesKit/Autofill/AutofillUserScript.swift b/Sources/BrowserServicesKit/Autofill/AutofillUserScript.swift
index 06325172c..7bb0c4001 100644
--- a/Sources/BrowserServicesKit/Autofill/AutofillUserScript.swift
+++ b/Sources/BrowserServicesKit/Autofill/AutofillUserScript.swift
@@ -17,9 +17,9 @@
//
import Common
-import WebKit
-import UserScript
import os.log
+import UserScript
+@preconcurrency import WebKit
var previousIncontextSignupPermanentlyDismissedAt: Double?
var previousEmailSignedIn: Bool?
diff --git a/Sources/BrowserServicesKit/ContentBlocking/UserScripts/SurrogatesUserScript.swift b/Sources/BrowserServicesKit/ContentBlocking/UserScripts/SurrogatesUserScript.swift
index ba10bf76f..0c83b7dd8 100644
--- a/Sources/BrowserServicesKit/ContentBlocking/UserScripts/SurrogatesUserScript.swift
+++ b/Sources/BrowserServicesKit/ContentBlocking/UserScripts/SurrogatesUserScript.swift
@@ -16,11 +16,11 @@
// limitations under the License.
//
-import WebKit
+import Common
+import ContentBlocking
import TrackerRadarKit
import UserScript
-import ContentBlocking
-import Common
+@preconcurrency import WebKit
public protocol SurrogatesUserScriptDelegate: NSObjectProtocol {
diff --git a/Sources/BrowserServicesKit/ContentScopeScript/SpecialPagesUserScript.swift b/Sources/BrowserServicesKit/ContentScopeScript/SpecialPagesUserScript.swift
index 215dbcc6f..f29a6e520 100644
--- a/Sources/BrowserServicesKit/ContentScopeScript/SpecialPagesUserScript.swift
+++ b/Sources/BrowserServicesKit/ContentScopeScript/SpecialPagesUserScript.swift
@@ -43,8 +43,7 @@ public final class SpecialPagesUserScript: NSObject, UserScript, UserScriptMessa
@available(macOS 11.0, iOS 14.0, *)
extension SpecialPagesUserScript: WKScriptMessageHandlerWithReply {
@MainActor
- public func userContentController(_ userContentController: WKUserContentController,
- didReceive message: WKScriptMessage) async -> (Any?, String?) {
+ public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) async -> (Any?, String?) {
let action = broker.messageHandlerFor(message)
do {
let json = try await broker.execute(action: action, original: message)
diff --git a/Sources/BrowserServicesKit/ContentScopeScript/UserContentController.swift b/Sources/BrowserServicesKit/ContentScopeScript/UserContentController.swift
index 9d534abe2..8c2ee2169 100644
--- a/Sources/BrowserServicesKit/ContentScopeScript/UserContentController.swift
+++ b/Sources/BrowserServicesKit/ContentScopeScript/UserContentController.swift
@@ -18,10 +18,10 @@
import Combine
import Common
-import UserScript
-import WebKit
-import QuartzCore
import os.log
+import QuartzCore
+import UserScript
+@preconcurrency import WebKit
public protocol UserContentControllerDelegate: AnyObject {
@MainActor
diff --git a/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift b/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift
index de58f2c1b..c8a7ea892 100644
--- a/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift
+++ b/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift
@@ -50,7 +50,7 @@ public enum PrivacyFeature: String {
case sslCertificates
case brokenSiteReportExperiment
case toggleReports
- case phishingDetection
+ case maliciousSiteProtection
case brokenSitePrompt
case remoteMessaging
case additionalCampaignPixelParams
@@ -182,10 +182,9 @@ public enum DuckPlayerSubfeature: String, PrivacySubfeature {
case enableDuckPlayer // iOS DuckPlayer rollout feature
}
-public enum PhishingDetectionSubfeature: String, PrivacySubfeature {
- public var parent: PrivacyFeature { .phishingDetection }
+public enum MaliciousSiteProtectionSubfeature: String, PrivacySubfeature {
+ public var parent: PrivacyFeature { .maliciousSiteProtection }
case allowErrorPage
- case allowPreferencesToggle
}
public enum SyncPromotionSubfeature: String, PrivacySubfeature {
diff --git a/Sources/Common/Concurrency/TaskExtension.swift b/Sources/Common/Concurrency/TaskExtension.swift
index a407e4406..65d974a36 100644
--- a/Sources/Common/Concurrency/TaskExtension.swift
+++ b/Sources/Common/Concurrency/TaskExtension.swift
@@ -18,51 +18,72 @@
import Foundation
+public struct Sleeper {
+
+ public static let `default` = Sleeper(sleep: {
+ try await Task.sleep(interval: $0)
+ })
+
+ private let sleep: (TimeInterval) async throws -> Void
+
+ public init(sleep: @escaping (TimeInterval) async throws -> Void) {
+ self.sleep = sleep
+ }
+
+ @available(macOS 13.0, iOS 16.0, *)
+ public init(clock: any Clock) {
+ self.sleep = { interval in
+ try await clock.sleep(for: .nanoseconds(UInt64(interval * Double(NSEC_PER_SEC))))
+ }
+ }
+
+ public func sleep(for interval: TimeInterval) async throws {
+ try await sleep(interval)
+ }
+
+}
+
+public func performPeriodicJob(withDelay delay: TimeInterval? = nil,
+ interval: TimeInterval,
+ sleeper: Sleeper = .default,
+ operation: @escaping @Sendable () async throws -> Void,
+ cancellationHandler: (@Sendable () async -> Void)? = nil) async throws -> Never {
+
+ do {
+ if let delay {
+ try await sleeper.sleep(for: delay)
+ }
+
+ repeat {
+ try await operation()
+
+ try await sleeper.sleep(for: interval)
+ } while true
+ } catch let error as CancellationError {
+ await cancellationHandler?()
+ throw error
+ }
+}
+
public extension Task where Success == Never, Failure == Error {
static func periodic(delay: TimeInterval? = nil,
interval: TimeInterval,
+ sleeper: Sleeper = .default,
operation: @escaping @Sendable () async -> Void,
cancellationHandler: (@Sendable () async -> Void)? = nil) -> Task {
- Task {
- do {
- if let delay {
- try await Task.sleep(interval: delay)
- }
-
- repeat {
- await operation()
-
- try await Task.sleep(interval: interval)
- } while true
- } catch {
- await cancellationHandler?()
- throw error
- }
- }
+ return periodic(delay: delay, interval: interval, sleeper: sleeper, operation: { await operation() } as @Sendable () async throws -> Void, cancellationHandler: cancellationHandler)
}
static func periodic(delay: TimeInterval? = nil,
interval: TimeInterval,
+ sleeper: Sleeper = .default,
operation: @escaping @Sendable () async throws -> Void,
cancellationHandler: (@Sendable () async -> Void)? = nil) -> Task {
Task {
- do {
- if let delay {
- try await Task.sleep(interval: delay)
- }
-
- repeat {
- try await operation()
-
- try await Task.sleep(interval: interval)
- } while true
- } catch {
- await cancellationHandler?()
- throw error
- }
+ try await performPeriodicJob(withDelay: delay, interval: interval, sleeper: sleeper, operation: operation, cancellationHandler: cancellationHandler)
}
}
}
diff --git a/Sources/Common/Extensions/HashExtension.swift b/Sources/Common/Extensions/HashExtension.swift
index b6752cf57..13095cf63 100644
--- a/Sources/Common/Extensions/HashExtension.swift
+++ b/Sources/Common/Extensions/HashExtension.swift
@@ -42,8 +42,13 @@ extension Data {
extension String {
public var sha1: String {
- let dataBytes = data(using: .utf8)!
- return dataBytes.sha1
+ let result = utf8data.sha1
+ return result
+ }
+
+ public var sha256: String {
+ let result = utf8data.sha256
+ return result
}
}
diff --git a/Sources/Common/Extensions/StringExtension.swift b/Sources/Common/Extensions/StringExtension.swift
index 09050cfe2..9282a43b4 100644
--- a/Sources/Common/Extensions/StringExtension.swift
+++ b/Sources/Common/Extensions/StringExtension.swift
@@ -394,9 +394,9 @@ public extension String {
// MARK: Regex
- func matches(_ regex: NSRegularExpression) -> Bool {
- let matches = regex.matches(in: self, options: .anchored, range: self.fullRange)
- return matches.count == 1
+ func matches(_ regex: RegEx) -> Bool {
+ let firstMatch = firstMatch(of: regex, options: .anchored)
+ return firstMatch != nil
}
func matches(pattern: String, options: NSRegularExpression.Options = [.caseInsensitive]) -> Bool {
@@ -406,7 +406,7 @@ public extension String {
return matches(regex)
}
- func replacing(_ regex: NSRegularExpression, with replacement: String) -> String {
+ func replacing(_ regex: RegEx, with replacement: String) -> String {
regex.stringByReplacingMatches(in: self, range: self.fullRange, withTemplate: replacement)
}
diff --git a/Sources/Common/Extensions/URLExtension.swift b/Sources/Common/Extensions/URLExtension.swift
index d19751148..ce68773d5 100644
--- a/Sources/Common/Extensions/URLExtension.swift
+++ b/Sources/Common/Extensions/URLExtension.swift
@@ -354,22 +354,24 @@ extension URL {
// MARK: - Parameters
+ @_disfavoredOverload // prefer ordered KeyValuePairs collection when `parameters` passed as a Dictionary literal to preserve order.
public func appendingParameters(_ parameters: QueryParams, allowedReservedCharacters: CharacterSet? = nil) -> URL
where QueryParams.Element == (key: String, value: String) {
+ let result = self.appending(percentEncodedQueryItems: parameters.map { name, value in
+ URLQueryItem(percentEncodingName: name, value: value, withAllowedCharacters: allowedReservedCharacters)
+ })
+ return result
+ }
- return parameters.reduce(self) { partialResult, parameter in
- partialResult.appendingParameter(
- name: parameter.key,
- value: parameter.value,
- allowedReservedCharacters: allowedReservedCharacters
- )
- }
+ public func appendingParameters(_ parameters: KeyValuePairs, allowedReservedCharacters: CharacterSet? = nil) -> URL {
+ let result = self.appending(percentEncodedQueryItems: parameters.map { name, value in
+ URLQueryItem(percentEncodingName: name, value: value, withAllowedCharacters: allowedReservedCharacters)
+ })
+ return result
}
public func appendingParameter(name: String, value: String, allowedReservedCharacters: CharacterSet? = nil) -> URL {
- let queryItem = URLQueryItem(percentEncodingName: name,
- value: value,
- withAllowedCharacters: allowedReservedCharacters)
+ let queryItem = URLQueryItem(percentEncodingName: name, value: value, withAllowedCharacters: allowedReservedCharacters)
return self.appending(percentEncodedQueryItem: queryItem)
}
@@ -378,13 +380,15 @@ extension URL {
}
public func appending(percentEncodedQueryItems: [URLQueryItem]) -> URL {
- guard var components = URLComponents(url: self, resolvingAgainstBaseURL: true) else { return self }
+ guard !percentEncodedQueryItems.isEmpty,
+ var components = URLComponents(url: self, resolvingAgainstBaseURL: true) else { return self }
var existingPercentEncodedQueryItems = components.percentEncodedQueryItems ?? [URLQueryItem]()
existingPercentEncodedQueryItems.append(contentsOf: percentEncodedQueryItems)
components.percentEncodedQueryItems = existingPercentEncodedQueryItems
+ let result = components.url ?? self
- return components.url ?? self
+ return result
}
public func getQueryItems() -> [URLQueryItem]? {
diff --git a/Sources/MaliciousSiteProtection/API/APIClient.swift b/Sources/MaliciousSiteProtection/API/APIClient.swift
new file mode 100644
index 000000000..d16669d4b
--- /dev/null
+++ b/Sources/MaliciousSiteProtection/API/APIClient.swift
@@ -0,0 +1,129 @@
+//
+// APIClient.swift
+//
+// Copyright © 2024 DuckDuckGo. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Common
+import Foundation
+import Networking
+
+extension APIClient {
+ // used internally for testing
+ protocol Mockable {
+ func load(_ requestConfig: Request) async throws -> Request.Response
+ }
+}
+extension APIClient: APIClient.Mockable {}
+
+public protocol APIClientEnvironment {
+ func headers(for requestType: APIRequestType) -> APIRequestV2.HeadersV2
+ func url(for requestType: APIRequestType) -> URL
+}
+
+public extension MaliciousSiteDetector {
+ enum APIEnvironment: APIClientEnvironment {
+
+ case production
+ case staging
+
+ var endpoint: URL {
+ switch self {
+ case .production: URL(string: "https://duckduckgo.com/api/protection/")!
+ case .staging: URL(string: "https://staging.duckduckgo.com/api/protection/")!
+ }
+ }
+
+ var defaultHeaders: APIRequestV2.HeadersV2 {
+ .init(userAgent: Networking.APIRequest.Headers.userAgent)
+ }
+
+ enum APIPath {
+ static let filterSet = "filterSet"
+ static let hashPrefix = "hashPrefix"
+ static let matches = "matches"
+ }
+
+ enum QueryParameter {
+ static let category = "category"
+ static let revision = "revision"
+ static let hashPrefix = "hashPrefix"
+ }
+
+ public func url(for requestType: APIRequestType) -> URL {
+ switch requestType {
+ case .hashPrefixSet(let configuration):
+ endpoint.appendingPathComponent(APIPath.hashPrefix).appendingParameters([
+ QueryParameter.category: configuration.threatKind.rawValue,
+ QueryParameter.revision: (configuration.revision ?? 0).description,
+ ])
+ case .filterSet(let configuration):
+ endpoint.appendingPathComponent(APIPath.filterSet).appendingParameters([
+ QueryParameter.category: configuration.threatKind.rawValue,
+ QueryParameter.revision: (configuration.revision ?? 0).description,
+ ])
+ case .matches(let configuration):
+ endpoint.appendingPathComponent(APIPath.matches).appendingParameter(name: QueryParameter.hashPrefix, value: configuration.hashPrefix)
+ }
+ }
+
+ public func headers(for requestType: APIRequestType) -> APIRequestV2.HeadersV2 {
+ defaultHeaders
+ }
+ }
+
+}
+
+struct APIClient {
+
+ let environment: APIClientEnvironment
+ private let service: APIService
+
+ init(environment: APIClientEnvironment, service: APIService = DefaultAPIService(urlSession: .shared)) {
+ self.environment = environment
+ self.service = service
+ }
+
+ func load(_ requestConfig: R) async throws -> R.Response {
+ let requestType = requestConfig.requestType
+ let headers = environment.headers(for: requestType)
+ let url = environment.url(for: requestType)
+
+ let apiRequest = APIRequestV2(url: url, method: .get, headers: headers, timeoutInterval: requestConfig.timeout ?? 60)
+ let response = try await service.fetch(request: apiRequest)
+ let result: R.Response = try response.decodeBody()
+
+ return result
+ }
+
+}
+
+// MARK: - Convenience
+extension APIClient.Mockable {
+ func filtersChangeSet(for threatKind: ThreatKind, revision: Int) async throws -> APIClient.Response.FiltersChangeSet {
+ let result = try await load(.filterSet(threatKind: threatKind, revision: revision))
+ return result
+ }
+
+ func hashPrefixesChangeSet(for threatKind: ThreatKind, revision: Int) async throws -> APIClient.Response.HashPrefixesChangeSet {
+ let result = try await load(.hashPrefixes(threatKind: threatKind, revision: revision))
+ return result
+ }
+
+ func matches(forHashPrefix hashPrefix: String) async throws -> APIClient.Response.Matches {
+ let result = try await load(.matches(hashPrefix: hashPrefix))
+ return result
+ }
+}
diff --git a/Sources/MaliciousSiteProtection/API/APIRequest.swift b/Sources/MaliciousSiteProtection/API/APIRequest.swift
new file mode 100644
index 000000000..39fb623bd
--- /dev/null
+++ b/Sources/MaliciousSiteProtection/API/APIRequest.swift
@@ -0,0 +1,112 @@
+//
+// APIRequest.swift
+//
+// Copyright © 2024 DuckDuckGo. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+
+// Enumerated request type to delegate URLs forming to an API environment instance
+public enum APIRequestType {
+ case hashPrefixSet(APIRequestType.HashPrefixes)
+ case filterSet(APIRequestType.FilterSet)
+ case matches(APIRequestType.Matches)
+}
+
+extension APIClient {
+ // Protocol for defining typed requests with a specific response type.
+ protocol Request {
+ associatedtype Response: Decodable // Strongly-typed response type
+ var requestType: APIRequestType { get } // Enumerated type of request being made
+ var timeout: TimeInterval? { get }
+ }
+
+ // Protocol for requests that modify a set of malicious site detection data
+ // (returning insertions/removals along with the updated revision)
+ protocol ChangeSetRequest: Request {
+ init(threatKind: ThreatKind, revision: Int?)
+ }
+}
+extension APIClient.Request {
+ var timeout: TimeInterval? { nil }
+}
+
+public extension APIRequestType {
+ struct HashPrefixes: APIClient.ChangeSetRequest {
+ typealias Response = APIClient.Response.HashPrefixesChangeSet
+
+ let threatKind: ThreatKind
+ let revision: Int?
+
+ init(threatKind: ThreatKind, revision: Int?) {
+ self.threatKind = threatKind
+ self.revision = revision
+ }
+
+ var requestType: APIRequestType {
+ .hashPrefixSet(self)
+ }
+ }
+}
+/// extension to call generic `load(_: some Request)` method like this: `load(.hashPrefixes(…))`
+extension APIClient.Request where Self == APIRequestType.HashPrefixes {
+ static func hashPrefixes(threatKind: ThreatKind, revision: Int?) -> Self {
+ .init(threatKind: threatKind, revision: revision)
+ }
+}
+
+public extension APIRequestType {
+ struct FilterSet: APIClient.ChangeSetRequest {
+ typealias Response = APIClient.Response.FiltersChangeSet
+
+ let threatKind: ThreatKind
+ let revision: Int?
+
+ init(threatKind: ThreatKind, revision: Int?) {
+ self.threatKind = threatKind
+ self.revision = revision
+ }
+
+ var requestType: APIRequestType {
+ .filterSet(self)
+ }
+ }
+}
+/// extension to call generic `load(_: some Request)` method like this: `load(.filterSet(…))`
+extension APIClient.Request where Self == APIRequestType.FilterSet {
+ static func filterSet(threatKind: ThreatKind, revision: Int?) -> Self {
+ .init(threatKind: threatKind, revision: revision)
+ }
+}
+
+public extension APIRequestType {
+ struct Matches: APIClient.Request {
+ typealias Response = APIClient.Response.Matches
+
+ let hashPrefix: String
+
+ var requestType: APIRequestType {
+ .matches(self)
+ }
+
+ var timeout: TimeInterval? { 1 }
+ }
+}
+/// extension to call generic `load(_: some Request)` method like this: `load(.matches(…))`
+extension APIClient.Request where Self == APIRequestType.Matches {
+ static func matches(hashPrefix: String) -> Self {
+ .init(hashPrefix: hashPrefix)
+ }
+}
diff --git a/Sources/MaliciousSiteProtection/API/ChangeSetResponse.swift b/Sources/MaliciousSiteProtection/API/ChangeSetResponse.swift
new file mode 100644
index 000000000..eaf4f287c
--- /dev/null
+++ b/Sources/MaliciousSiteProtection/API/ChangeSetResponse.swift
@@ -0,0 +1,47 @@
+//
+// ChangeSetResponse.swift
+//
+// Copyright © 2024 DuckDuckGo. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+
+extension APIClient {
+
+ public struct ChangeSetResponse: Codable, Equatable {
+ let insert: [T]
+ let delete: [T]
+ let revision: Int
+ let replace: Bool
+
+ public init(insert: [T], delete: [T], revision: Int, replace: Bool) {
+ self.insert = insert
+ self.delete = delete
+ self.revision = revision
+ self.replace = replace
+ }
+
+ public var isEmpty: Bool {
+ insert.isEmpty && delete.isEmpty
+ }
+ }
+
+ public enum Response {
+ public typealias FiltersChangeSet = ChangeSetResponse
+ public typealias HashPrefixesChangeSet = ChangeSetResponse
+ public typealias Matches = MatchResponse
+ }
+
+}
diff --git a/Sources/MaliciousSiteProtection/API/MatchResponse.swift b/Sources/MaliciousSiteProtection/API/MatchResponse.swift
new file mode 100644
index 000000000..2cb6df962
--- /dev/null
+++ b/Sources/MaliciousSiteProtection/API/MatchResponse.swift
@@ -0,0 +1,29 @@
+//
+// MatchResponse.swift
+//
+// Copyright © 2024 DuckDuckGo. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+extension APIClient {
+
+ public struct MatchResponse: Codable, Equatable {
+ public var matches: [Match]
+
+ public init(matches: [Match]) {
+ self.matches = matches
+ }
+ }
+
+}
diff --git a/Sources/Networking/v2/Extensions/Dictionary+URLQueryItem.swift b/Sources/MaliciousSiteProtection/Logger+MaliciousSiteProtection.swift
similarity index 53%
rename from Sources/Networking/v2/Extensions/Dictionary+URLQueryItem.swift
rename to Sources/MaliciousSiteProtection/Logger+MaliciousSiteProtection.swift
index 81a4648d6..3e44f3bcd 100644
--- a/Sources/Networking/v2/Extensions/Dictionary+URLQueryItem.swift
+++ b/Sources/MaliciousSiteProtection/Logger+MaliciousSiteProtection.swift
@@ -1,5 +1,5 @@
//
-// Dictionary+URLQueryItem.swift
+// Logger+MaliciousSiteProtection.swift
//
// Copyright © 2024 DuckDuckGo. All rights reserved.
//
@@ -17,19 +17,15 @@
//
import Foundation
-import Common
+import os
-extension Dictionary where Key == String, Value == String {
-
- func toURLQueryItems(allowedReservedCharacters: CharacterSet? = nil) -> [URLQueryItem] {
- return self.map {
- if let allowedReservedCharacters {
- URLQueryItem(percentEncodingName: $0.key,
- value: $0.value,
- withAllowedCharacters: allowedReservedCharacters)
- } else {
- URLQueryItem(name: $0.key, value: $0.value)
- }
- }
+public extension os.Logger {
+ struct MaliciousSiteProtection {
+ public static var general = os.Logger(subsystem: "MSP", category: "General")
+ public static var api = os.Logger(subsystem: "MSP", category: "API")
+ public static var dataManager = os.Logger(subsystem: "MSP", category: "DataManager")
+ public static var updateManager = os.Logger(subsystem: "MSP", category: "UpdateManager")
}
}
+
+internal typealias Logger = os.Logger.MaliciousSiteProtection
diff --git a/Sources/MaliciousSiteProtection/MaliciousSiteDetector.swift b/Sources/MaliciousSiteProtection/MaliciousSiteDetector.swift
new file mode 100644
index 000000000..1a637a73a
--- /dev/null
+++ b/Sources/MaliciousSiteProtection/MaliciousSiteDetector.swift
@@ -0,0 +1,130 @@
+//
+// MaliciousSiteDetector.swift
+//
+// Copyright © 2024 DuckDuckGo. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Common
+import CryptoKit
+import Foundation
+import Networking
+
+public protocol MaliciousSiteDetecting {
+ /// Evaluates the given URL to determine its malicious category (e.g., phishing, malware).
+ /// - Parameter url: The URL to evaluate.
+ /// - Returns: An optional `ThreatKind` indicating the type of threat, or `.none` if no threat is detected.
+ func evaluate(_ url: URL) async -> ThreatKind?
+}
+
+/// Class responsible for detecting malicious sites by evaluating URLs against local filters and an external API.
+/// entry point: `func evaluate(_: URL) async -> ThreatKind?`
+public final class MaliciousSiteDetector: MaliciousSiteDetecting {
+ // Type aliases for easier symbol navigation in Xcode.
+ typealias PhishingDetector = MaliciousSiteDetector
+ typealias MalwareDetector = MaliciousSiteDetector
+
+ private enum Constants {
+ static let hashPrefixStoreLength: Int = 8
+ static let hashPrefixParamLength: Int = 4
+ }
+
+ private let apiClient: APIClient.Mockable
+ private let dataManager: DataManaging
+ private let eventMapping: EventMapping
+
+ public convenience init(apiEnvironment: APIClientEnvironment, service: APIService = DefaultAPIService(urlSession: .shared), dataManager: DataManager, eventMapping: EventMapping) {
+ self.init(apiClient: APIClient(environment: apiEnvironment, service: service), dataManager: dataManager, eventMapping: eventMapping)
+ }
+
+ init(apiClient: APIClient.Mockable, dataManager: DataManaging, eventMapping: EventMapping) {
+ self.apiClient = apiClient
+ self.dataManager = dataManager
+ self.eventMapping = eventMapping
+ }
+
+ private func checkLocalFilters(hostHash: String, canonicalUrl: URL, for threatKind: ThreatKind) async -> Bool {
+ let filterSet = await dataManager.dataSet(for: .filterSet(threatKind: threatKind))
+ let matchesLocalFilters = filterSet[hostHash]?.contains(where: { regex in
+ canonicalUrl.absoluteString.matches(pattern: regex)
+ }) ?? false
+
+ return matchesLocalFilters
+ }
+
+ private func checkApiMatches(hostHash: String, canonicalUrl: URL) async -> Match? {
+ let hashPrefixParam = String(hostHash.prefix(Constants.hashPrefixParamLength))
+ let matches: [Match]
+ do {
+ matches = try await apiClient.matches(forHashPrefix: hashPrefixParam).matches
+ } catch {
+ Logger.general.error("Error fetching matches from API: \(error)")
+ return nil
+ }
+
+ if let match = matches.first(where: { match in
+ match.hash == hostHash && canonicalUrl.absoluteString.matches(pattern: match.regex)
+ }) {
+ return match
+ }
+ return nil
+ }
+
+ /// Evaluates the given URL to determine its malicious category (e.g., phishing, malware).
+ public func evaluate(_ url: URL) async -> ThreatKind? {
+ guard let canonicalHost = url.canonicalHost(),
+ let canonicalUrl = url.canonicalURL() else { return .none }
+
+ let hostHash = canonicalHost.sha256
+ let hashPrefix = String(hostHash.prefix(Constants.hashPrefixStoreLength))
+
+ // 1. Check for matching hash prefixes.
+ // The hash prefix list serves as a representation of the entire database:
+ // every malicious website will have a hash prefix that it collides with.
+ var hashPrefixMatchingThreatKinds = [ThreatKind]()
+ for threatKind in ThreatKind.allCases { // e.g., phishing, malware, etc.
+ let hashPrefixes = await dataManager.dataSet(for: .hashPrefixes(threatKind: threatKind))
+ if hashPrefixes.contains(hashPrefix) {
+ hashPrefixMatchingThreatKinds.append(threatKind)
+ }
+ }
+
+ // Return no threats if no matching hash prefixes are found in the database.
+ guard !hashPrefixMatchingThreatKinds.isEmpty else { return .none }
+
+ // 2. Check local Filter Sets.
+ // The filter set acts as a local cache of some database entries, containing
+ // the 5000 most common threats (or those most likely to collide with daily
+ // browsing behaviors, based on Clickhouse's top 10k, ranked by Netcraft's risk rating).
+ for threatKind in hashPrefixMatchingThreatKinds {
+ let matches = await checkLocalFilters(hostHash: hostHash, canonicalUrl: canonicalUrl, for: threatKind)
+ if matches {
+ eventMapping.fire(.errorPageShown(clientSideHit: true, threatKind: threatKind))
+ return threatKind
+ }
+ }
+
+ // 3. If no locally cached filters matched, we will still make a request to the API
+ // to check for potential matches on our backend.
+ let match = await checkApiMatches(hostHash: hostHash, canonicalUrl: canonicalUrl)
+ if let match {
+ let threatKind = match.category.flatMap(ThreatKind.init) ?? hashPrefixMatchingThreatKinds[0]
+ eventMapping.fire(.errorPageShown(clientSideHit: false, threatKind: threatKind))
+ return threatKind
+ }
+
+ return .none
+ }
+
+}
diff --git a/Sources/PhishingDetection/PhishingDetectionEvents.swift b/Sources/MaliciousSiteProtection/Model/Event.swift
similarity index 92%
rename from Sources/PhishingDetection/PhishingDetectionEvents.swift
rename to Sources/MaliciousSiteProtection/Model/Event.swift
index a788e09ff..8903f4d70 100644
--- a/Sources/PhishingDetection/PhishingDetectionEvents.swift
+++ b/Sources/MaliciousSiteProtection/Model/Event.swift
@@ -1,5 +1,5 @@
//
-// PhishingDetectionEvents.swift
+// Event.swift
//
// Copyright © 2024 DuckDuckGo. All rights reserved.
//
@@ -26,8 +26,8 @@ public extension PixelKit {
}
}
-public enum PhishingDetectionEvents: PixelKitEventV2 {
- case errorPageShown(clientSideHit: Bool)
+public enum Event: PixelKitEventV2 {
+ case errorPageShown(clientSideHit: Bool, threatKind: ThreatKind)
case visitSite
case iframeLoaded
case updateTaskFailed48h(error: Error?)
@@ -50,7 +50,7 @@ public enum PhishingDetectionEvents: PixelKitEventV2 {
public var parameters: [String: String]? {
switch self {
- case .errorPageShown(let clientSideHit):
+ case .errorPageShown(let clientSideHit, threatKind: _):
return [PixelKit.Parameters.clientSideHit: String(clientSideHit)]
case .visitSite:
return [:]
diff --git a/Tests/PhishingDetectionTests/Mocks/BackgroundActivitySchedulerMock.swift b/Sources/MaliciousSiteProtection/Model/Filter.swift
similarity index 65%
rename from Tests/PhishingDetectionTests/Mocks/BackgroundActivitySchedulerMock.swift
rename to Sources/MaliciousSiteProtection/Model/Filter.swift
index 86b79d477..674a176e0 100644
--- a/Tests/PhishingDetectionTests/Mocks/BackgroundActivitySchedulerMock.swift
+++ b/Sources/MaliciousSiteProtection/Model/Filter.swift
@@ -1,5 +1,5 @@
//
-// BackgroundActivitySchedulerMock.swift
+// Filter.swift
//
// Copyright © 2024 DuckDuckGo. All rights reserved.
//
@@ -17,19 +17,18 @@
//
import Foundation
-import PhishingDetection
-actor MockBackgroundActivityScheduler: BackgroundActivityScheduling {
- var startCalled = false
- var stopCalled = false
- var interval: TimeInterval = 1
- var identifier: String = "test"
+public struct Filter: Codable, Hashable {
+ public var hash: String
+ public var regex: String
- func start() {
- startCalled = true
+ enum CodingKeys: String, CodingKey {
+ case hash
+ case regex
}
- func stop() {
- stopCalled = true
+ public init(hash: String, regex: String) {
+ self.hash = hash
+ self.regex = regex
}
}
diff --git a/Sources/MaliciousSiteProtection/Model/FilterDictionary.swift b/Sources/MaliciousSiteProtection/Model/FilterDictionary.swift
new file mode 100644
index 000000000..b67cd82ef
--- /dev/null
+++ b/Sources/MaliciousSiteProtection/Model/FilterDictionary.swift
@@ -0,0 +1,78 @@
+//
+// FilterDictionary.swift
+//
+// Copyright © 2024 DuckDuckGo. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+
+struct FilterDictionary: Codable, Equatable {
+
+ /// Filter set revision
+ var revision: Int
+
+ /// [Hash: [RegEx]] mapping
+ ///
+ /// - **Key**: SHA256 hash sum of a canonical host name
+ /// - **Value**: An array of regex patterns used to match whole URLs
+ ///
+ /// ```
+ /// {
+ /// "3aeb002460381c6f258e8395d3026f571f0d9a76488dcd837639b13aed316560" : [
+ /// "(?i)^https?\\:\\/\\/[\\w\\-\\.]+(?:\\:(?:80|443))?[\\/\\\\]+BETS1O\\-GIRIS[\\/\\\\]+BETS1O(?:[\\/\\\\]+|\\?|$)"
+ /// ],
+ /// ...
+ /// }
+ /// ```
+ var filters: [String: Set]
+
+ /// Subscript to access regex patterns by SHA256 host name hash
+ subscript(hash: String) -> Set? {
+ filters[hash]
+ }
+
+ mutating func subtract(_ itemsToDelete: Seq) where Seq.Element == Filter {
+ for filter in itemsToDelete {
+ // Remove the filter from the Set stored in the Dictionary by hash used as a key.
+ // If the Set becomes empty – remove the Set value from the Dictionary.
+ //
+ // The following code is equivalent to this one but without the Set value being copied
+ // or key being searched multiple times:
+ /*
+ if var filterSet = self.filters[filter.hash] {
+ filterSet.remove(filter.regex)
+ if filterSet.isEmpty {
+ self.filters[filter.hash] = nil
+ } else {
+ self.filters[filter.hash] = filterSet
+ }
+ }
+ */
+ withUnsafeMutablePointer(to: &filters[filter.hash]) { item in
+ item.pointee?.remove(filter.regex)
+ if item.pointee?.isEmpty == true {
+ item.pointee = nil
+ }
+ }
+ }
+ }
+
+ mutating func formUnion(_ itemsToAdd: Seq) where Seq.Element == Filter {
+ for filter in itemsToAdd {
+ filters[filter.hash, default: []].insert(filter.regex)
+ }
+ }
+
+}
diff --git a/Sources/MaliciousSiteProtection/Model/HashPrefixSet.swift b/Sources/MaliciousSiteProtection/Model/HashPrefixSet.swift
new file mode 100644
index 000000000..7aec5244d
--- /dev/null
+++ b/Sources/MaliciousSiteProtection/Model/HashPrefixSet.swift
@@ -0,0 +1,45 @@
+//
+// HashPrefixSet.swift
+//
+// Copyright © 2024 DuckDuckGo. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+
+/// Structure storing a Set of hash prefixes ["6fe1e7c8","1d760415",...] and a revision of the set.
+struct HashPrefixSet: Codable, Equatable {
+
+ var revision: Int
+ var set: Set
+
+ init(revision: Int, items: some Sequence) {
+ self.revision = revision
+ self.set = Set(items)
+ }
+
+ mutating func subtract(_ itemsToDelete: Seq) where Seq.Element == String {
+ set.subtract(itemsToDelete)
+ }
+
+ mutating func formUnion(_ itemsToAdd: Seq) where Seq.Element == String {
+ set.formUnion(itemsToAdd)
+ }
+
+ @inline(__always)
+ func contains(_ item: String) -> Bool {
+ set.contains(item)
+ }
+
+}
diff --git a/Sources/MaliciousSiteProtection/Model/IncrementallyUpdatableDataSet.swift b/Sources/MaliciousSiteProtection/Model/IncrementallyUpdatableDataSet.swift
new file mode 100644
index 000000000..8a23785ae
--- /dev/null
+++ b/Sources/MaliciousSiteProtection/Model/IncrementallyUpdatableDataSet.swift
@@ -0,0 +1,71 @@
+//
+// IncrementallyUpdatableDataSet.swift
+//
+// Copyright © 2024 DuckDuckGo. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+protocol IncrementallyUpdatableDataSet: Codable, Equatable {
+ /// Set Element Type (Hash Prefix or Filter)
+ associatedtype Element: Codable, Hashable
+ /// API Request type used to fetch updates for the data set
+ associatedtype APIRequest: APIClient.ChangeSetRequest where APIRequest.Response == APIClient.ChangeSetResponse
+
+ var revision: Int { get set }
+
+ init(revision: Int, items: some Sequence)
+
+ mutating func subtract(_ itemsToDelete: Seq) where Seq.Element == Element
+ mutating func formUnion(_ itemsToAdd: Seq) where Seq.Element == Element
+
+ /// Apply ChangeSet from local data revision to actual revision loaded from API
+ mutating func apply(_ changeSet: APIClient.ChangeSetResponse)
+}
+
+extension IncrementallyUpdatableDataSet {
+ mutating func apply(_ changeSet: APIClient.ChangeSetResponse) {
+ if changeSet.replace {
+ self = .init(revision: changeSet.revision, items: changeSet.insert)
+ } else {
+ self.subtract(changeSet.delete)
+ self.formUnion(changeSet.insert)
+ self.revision = changeSet.revision
+ }
+ }
+}
+
+extension HashPrefixSet: IncrementallyUpdatableDataSet {
+ typealias Element = String
+ typealias APIRequest = APIRequestType.HashPrefixes
+
+ static func apiRequest(for threatKind: ThreatKind, revision: Int) -> APIRequest {
+ .hashPrefixes(threatKind: threatKind, revision: revision)
+ }
+}
+
+extension FilterDictionary: IncrementallyUpdatableDataSet {
+ typealias Element = Filter
+ typealias APIRequest = APIRequestType.FilterSet
+
+ init(revision: Int, items: some Sequence) {
+ let filtersDictionary = items.reduce(into: [String: Set]()) { result, filter in
+ result[filter.hash, default: []].insert(filter.regex)
+ }
+ self.init(revision: revision, filters: filtersDictionary)
+ }
+
+ static func apiRequest(for threatKind: ThreatKind, revision: Int) -> APIRequest {
+ .filterSet(threatKind: threatKind, revision: revision)
+ }
+}
diff --git a/Sources/MaliciousSiteProtection/Model/LoadableFromEmbeddedData.swift b/Sources/MaliciousSiteProtection/Model/LoadableFromEmbeddedData.swift
new file mode 100644
index 000000000..be67cb6fc
--- /dev/null
+++ b/Sources/MaliciousSiteProtection/Model/LoadableFromEmbeddedData.swift
@@ -0,0 +1,34 @@
+//
+// LoadableFromEmbeddedData.swift
+//
+// Copyright © 2024 DuckDuckGo. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+public protocol LoadableFromEmbeddedData {
+ /// Set Element Type (Hash Prefix or Filter)
+ associatedtype Element
+ /// Decoded data type stored in the embedded json file
+ associatedtype EmbeddedDataSet: Decodable, Sequence where EmbeddedDataSet.Element == Self.Element
+
+ init(revision: Int, items: some Sequence)
+}
+
+extension HashPrefixSet: LoadableFromEmbeddedData {
+ public typealias EmbeddedDataSet = [String]
+}
+
+extension FilterDictionary: LoadableFromEmbeddedData {
+ public typealias EmbeddedDataSet = [Filter]
+}
diff --git a/Sources/MaliciousSiteProtection/Model/MaliciousSiteError.swift b/Sources/MaliciousSiteProtection/Model/MaliciousSiteError.swift
new file mode 100644
index 000000000..8da2523f5
--- /dev/null
+++ b/Sources/MaliciousSiteProtection/Model/MaliciousSiteError.swift
@@ -0,0 +1,94 @@
+//
+// MaliciousSiteError.swift
+//
+// Copyright © 2024 DuckDuckGo. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+
+public struct MaliciousSiteError: Error, Equatable {
+
+ public enum Code: Int {
+ case phishing = 1
+ // case malware = 2
+ }
+ public let code: Code
+ public let failingUrl: URL
+
+ public init(code: Code, failingUrl: URL) {
+ self.code = code
+ self.failingUrl = failingUrl
+ }
+
+ public init(threat: ThreatKind, failingUrl: URL) {
+ let code: Code
+ switch threat {
+ case .phishing:
+ code = .phishing
+ // case .malware:
+ // code = .malware
+ }
+ self.init(code: code, failingUrl: failingUrl)
+ }
+
+ public var threatKind: ThreatKind {
+ switch code {
+ case .phishing: .phishing
+ // case .malware: .malware
+ }
+ }
+
+}
+
+extension MaliciousSiteError: _ObjectiveCBridgeableError {
+
+ public init?(_bridgedNSError error: NSError) {
+ guard error.domain == MaliciousSiteError.errorDomain,
+ let code = Code(rawValue: error.code),
+ let failingUrl = error.userInfo[NSURLErrorFailingURLErrorKey] as? URL else { return nil }
+ self.code = code
+ self.failingUrl = failingUrl
+ }
+
+}
+
+extension MaliciousSiteError: LocalizedError {
+
+ public var errorDescription: String? {
+ switch code {
+ case .phishing:
+ return "Phishing detected"
+ // case .malware:
+ // return "Malware detected"
+ }
+ }
+
+}
+
+extension MaliciousSiteError: CustomNSError {
+ public static let errorDomain: String = "MaliciousSiteError"
+
+ public var errorCode: Int {
+ code.rawValue
+ }
+
+ public var errorUserInfo: [String: Any] {
+ [
+ NSURLErrorFailingURLErrorKey: failingUrl,
+ NSLocalizedDescriptionKey: errorDescription!
+ ]
+ }
+
+}
diff --git a/Tests/PhishingDetectionTests/Mocks/PhishingDetectorMock.swift b/Sources/MaliciousSiteProtection/Model/Match.swift
similarity index 52%
rename from Tests/PhishingDetectionTests/Mocks/PhishingDetectorMock.swift
rename to Sources/MaliciousSiteProtection/Model/Match.swift
index 4a56474e0..e22cb597f 100644
--- a/Tests/PhishingDetectionTests/Mocks/PhishingDetectorMock.swift
+++ b/Sources/MaliciousSiteProtection/Model/Match.swift
@@ -1,5 +1,5 @@
//
-// PhishingDetectorMock.swift
+// Match.swift
//
// Copyright © 2024 DuckDuckGo. All rights reserved.
//
@@ -17,22 +17,19 @@
//
import Foundation
-import PhishingDetection
-public class MockPhishingDetector: PhishingDetecting {
- private var mockClient: PhishingDetectionClientProtocol
- public var didCallIsMalicious: Bool = false
+public struct Match: Codable, Hashable {
+ var hostname: String
+ var url: String
+ var regex: String
+ var hash: String
+ let category: String?
- init() {
- self.mockClient = MockPhishingDetectionClient()
- }
-
- public func getMatches(hashPrefix: String) async -> Set {
- let matches = await mockClient.getMatches(hashPrefix: hashPrefix)
- return Set(matches)
- }
-
- public func isMalicious(url: URL) async -> Bool {
- return url.absoluteString.contains("malicious")
+ public init(hostname: String, url: String, regex: String, hash: String, category: String?) {
+ self.hostname = hostname
+ self.url = url
+ self.regex = regex
+ self.hash = hash
+ self.category = category
}
}
diff --git a/Sources/MaliciousSiteProtection/Model/StoredData.swift b/Sources/MaliciousSiteProtection/Model/StoredData.swift
new file mode 100644
index 000000000..a064be076
--- /dev/null
+++ b/Sources/MaliciousSiteProtection/Model/StoredData.swift
@@ -0,0 +1,104 @@
+//
+// StoredData.swift
+//
+// Copyright © 2024 DuckDuckGo. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+
+protocol MaliciousSiteDataKey: Hashable {
+ associatedtype EmbeddedDataSet: Decodable
+ associatedtype DataSet: IncrementallyUpdatableDataSet, LoadableFromEmbeddedData
+
+ var dataType: DataManager.StoredDataType { get }
+ var threatKind: ThreatKind { get }
+}
+
+public extension DataManager {
+ enum StoredDataType: Hashable, CaseIterable {
+ case hashPrefixSet(HashPrefixes)
+ case filterSet(FilterSet)
+
+ enum Kind: CaseIterable {
+ case hashPrefixSet, filterSet
+ }
+ // keep to get a compiler error when number of cases changes
+ var kind: Kind {
+ switch self {
+ case .hashPrefixSet: .hashPrefixSet
+ case .filterSet: .filterSet
+ }
+ }
+
+ var dataKey: any MaliciousSiteDataKey {
+ switch self {
+ case .hashPrefixSet(let key): key
+ case .filterSet(let key): key
+ }
+ }
+
+ public var threatKind: ThreatKind {
+ switch self {
+ case .hashPrefixSet(let key): key.threatKind
+ case .filterSet(let key): key.threatKind
+ }
+ }
+
+ public static var allCases: [DataManager.StoredDataType] {
+ ThreatKind.allCases.map { threatKind in
+ Kind.allCases.map { dataKind in
+ switch dataKind {
+ case .hashPrefixSet: .hashPrefixSet(.init(threatKind: threatKind))
+ case .filterSet: .filterSet(.init(threatKind: threatKind))
+ }
+ }
+ }.flatMap { $0 }
+ }
+ }
+}
+
+public extension DataManager.StoredDataType {
+ struct HashPrefixes: MaliciousSiteDataKey {
+ typealias DataSet = HashPrefixSet
+
+ let threatKind: ThreatKind
+
+ var dataType: DataManager.StoredDataType {
+ .hashPrefixSet(self)
+ }
+ }
+}
+extension MaliciousSiteDataKey where Self == DataManager.StoredDataType.HashPrefixes {
+ static func hashPrefixes(threatKind: ThreatKind) -> Self {
+ .init(threatKind: threatKind)
+ }
+}
+
+public extension DataManager.StoredDataType {
+ struct FilterSet: MaliciousSiteDataKey {
+ typealias DataSet = FilterDictionary
+
+ let threatKind: ThreatKind
+
+ var dataType: DataManager.StoredDataType {
+ .filterSet(self)
+ }
+ }
+}
+extension MaliciousSiteDataKey where Self == DataManager.StoredDataType.FilterSet {
+ static func filterSet(threatKind: ThreatKind) -> Self {
+ .init(threatKind: threatKind)
+ }
+}
diff --git a/Sources/MaliciousSiteProtection/Model/ThreatKind.swift b/Sources/MaliciousSiteProtection/Model/ThreatKind.swift
new file mode 100644
index 000000000..bec9e2996
--- /dev/null
+++ b/Sources/MaliciousSiteProtection/Model/ThreatKind.swift
@@ -0,0 +1,27 @@
+//
+// ThreatKind.swift
+//
+// Copyright © 2024 DuckDuckGo. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+
+public enum ThreatKind: String, CaseIterable, Codable, CustomStringConvertible {
+ public var description: String { rawValue }
+
+ case phishing
+ // case malware
+
+}
diff --git a/Sources/MaliciousSiteProtection/Services/DataManager.swift b/Sources/MaliciousSiteProtection/Services/DataManager.swift
new file mode 100644
index 000000000..8e4426dd1
--- /dev/null
+++ b/Sources/MaliciousSiteProtection/Services/DataManager.swift
@@ -0,0 +1,105 @@
+//
+// DataManager.swift
+//
+// Copyright © 2024 DuckDuckGo. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+import os
+
+protocol DataManaging {
+ func dataSet(for key: DataKey) async -> DataKey.DataSet
+ func store(_ dataSet: DataKey.DataSet, for key: DataKey) async
+}
+
+public actor DataManager: DataManaging {
+
+ private let embeddedDataProvider: EmbeddedDataProviding
+ private let fileStore: FileStoring
+
+ public typealias FileNameProvider = (DataManager.StoredDataType) -> String
+ private nonisolated let fileNameProvider: FileNameProvider
+
+ private var store: [StoredDataType: Any] = [:]
+
+ public init(fileStore: FileStoring, embeddedDataProvider: EmbeddedDataProviding, fileNameProvider: @escaping FileNameProvider) {
+ self.embeddedDataProvider = embeddedDataProvider
+ self.fileStore = fileStore
+ self.fileNameProvider = fileNameProvider
+ }
+
+ func dataSet(for key: DataKey) -> DataKey.DataSet {
+ let dataType = key.dataType
+ // return cached dataSet if available
+ if let data = store[key.dataType] as? DataKey.DataSet {
+ return data
+ }
+
+ // read stored dataSet if it‘s newer than the embedded one
+ let dataSet = readStoredDataSet(for: key) ?? {
+ // no stored dataSet or the embedded one is newer
+ let embeddedRevision = embeddedDataProvider.revision(for: dataType)
+ let embeddedItems = embeddedDataProvider.loadDataSet(for: key)
+ return .init(revision: embeddedRevision, items: embeddedItems)
+ }()
+
+ // cache
+ store[dataType] = dataSet
+
+ return dataSet
+ }
+
+ private func readStoredDataSet(for key: DataKey) -> DataKey.DataSet? {
+ let dataType = key.dataType
+ let fileName = fileNameProvider(dataType)
+ guard let data = fileStore.read(from: fileName) else { return nil }
+
+ let storedDataSet: DataKey.DataSet
+ do {
+ storedDataSet = try JSONDecoder().decode(DataKey.DataSet.self, from: data)
+ } catch {
+ Logger.dataManager.error("Error decoding \(fileName): \(error.localizedDescription)")
+ return nil
+ }
+
+ // compare to the embedded data revision
+ let embeddedDataRevision = embeddedDataProvider.revision(for: dataType)
+ guard storedDataSet.revision >= embeddedDataRevision else {
+ Logger.dataManager.error("Stored \(fileName) is outdated: revision: \(storedDataSet.revision), embedded revision: \(embeddedDataRevision).")
+ return nil
+ }
+
+ return storedDataSet
+ }
+
+ func store(_ dataSet: DataKey.DataSet, for key: DataKey) {
+ let dataType = key.dataType
+ let fileName = fileNameProvider(dataType)
+ self.store[dataType] = dataSet
+
+ let data: Data
+ do {
+ data = try JSONEncoder().encode(dataSet)
+ } catch {
+ Logger.dataManager.error("Error encoding \(fileName): \(error.localizedDescription)")
+ assertionFailure("Failed to store data to \(fileName): \(error)")
+ return
+ }
+
+ let success = fileStore.write(data: data, to: fileName)
+ assert(success)
+ }
+
+}
diff --git a/Sources/MaliciousSiteProtection/Services/EmbeddedDataProvider.swift b/Sources/MaliciousSiteProtection/Services/EmbeddedDataProvider.swift
new file mode 100644
index 000000000..942c6214a
--- /dev/null
+++ b/Sources/MaliciousSiteProtection/Services/EmbeddedDataProvider.swift
@@ -0,0 +1,56 @@
+//
+// EmbeddedDataProvider.swift
+//
+// Copyright © 2024 DuckDuckGo. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+import CryptoKit
+
+public protocol EmbeddedDataProviding {
+ func revision(for dataType: DataManager.StoredDataType) -> Int
+ func url(for dataType: DataManager.StoredDataType) -> URL
+ func hash(for dataType: DataManager.StoredDataType) -> String
+
+ func data(withContentsOf url: URL) throws -> Data
+}
+
+extension EmbeddedDataProviding {
+
+ func loadDataSet(for key: DataKey) -> DataKey.EmbeddedDataSet {
+ let dataType = key.dataType
+ let url = url(for: dataType)
+ let data: Data
+ do {
+ data = try self.data(withContentsOf: url)
+#if DEBUG
+ assert(data.sha256 == hash(for: dataType), "SHA mismatch for \(url.path)")
+#endif
+ } catch {
+ fatalError("\(self): Could not load embedded data set at “\(url)”: \(error)")
+ }
+ do {
+ let result = try JSONDecoder().decode(DataKey.EmbeddedDataSet.self, from: data)
+ return result
+ } catch {
+ fatalError("\(self): Could not decode embedded data set at “\(url)”: \(error)")
+ }
+ }
+
+ public func data(withContentsOf url: URL) throws -> Data {
+ try Data(contentsOf: url)
+ }
+
+}
diff --git a/Sources/MaliciousSiteProtection/Services/FileStore.swift b/Sources/MaliciousSiteProtection/Services/FileStore.swift
new file mode 100644
index 000000000..06418e6a2
--- /dev/null
+++ b/Sources/MaliciousSiteProtection/Services/FileStore.swift
@@ -0,0 +1,67 @@
+//
+// FileStore.swift
+//
+// Copyright © 2024 DuckDuckGo. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+import os
+
+public protocol FileStoring {
+ @discardableResult func write(data: Data, to filename: String) -> Bool
+ func read(from filename: String) -> Data?
+}
+
+public struct FileStore: FileStoring, CustomDebugStringConvertible {
+ private let dataStoreURL: URL
+
+ public init(dataStoreURL: URL) {
+ self.dataStoreURL = dataStoreURL
+ createDirectoryIfNeeded()
+ }
+
+ private func createDirectoryIfNeeded() {
+ do {
+ try FileManager.default.createDirectory(at: dataStoreURL, withIntermediateDirectories: true, attributes: nil)
+ } catch {
+ Logger.dataManager.error("Failed to create directory: \(error.localizedDescription)")
+ }
+ }
+
+ public func write(data: Data, to filename: String) -> Bool {
+ let fileURL = dataStoreURL.appendingPathComponent(filename)
+ do {
+ try data.write(to: fileURL)
+ return true
+ } catch {
+ Logger.dataManager.error("Error writing to directory: \(error.localizedDescription)")
+ return false
+ }
+ }
+
+ public func read(from filename: String) -> Data? {
+ let fileURL = dataStoreURL.appendingPathComponent(filename)
+ do {
+ return try Data(contentsOf: fileURL)
+ } catch {
+ Logger.dataManager.error("Error accessing application support directory: \(error)")
+ return nil
+ }
+ }
+
+ public var debugDescription: String {
+ return "<\(type(of: self)) - \"\(dataStoreURL.path)\">"
+ }
+}
diff --git a/Sources/MaliciousSiteProtection/Services/UpdateManager.swift b/Sources/MaliciousSiteProtection/Services/UpdateManager.swift
new file mode 100644
index 000000000..57394edbf
--- /dev/null
+++ b/Sources/MaliciousSiteProtection/Services/UpdateManager.swift
@@ -0,0 +1,101 @@
+//
+// UpdateManager.swift
+//
+// Copyright © 2024 DuckDuckGo. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Common
+import Foundation
+import Networking
+import os
+
+protocol UpdateManaging {
+ func updateData(for key: some MaliciousSiteDataKey) async
+
+ func startPeriodicUpdates() -> Task
+}
+
+public struct UpdateManager: UpdateManaging {
+
+ private let apiClient: APIClient.Mockable
+ private let dataManager: DataManaging
+
+ public typealias UpdateIntervalProvider = (DataManager.StoredDataType) -> TimeInterval?
+ private let updateIntervalProvider: UpdateIntervalProvider
+ private let sleeper: Sleeper
+
+ public init(apiEnvironment: APIClientEnvironment, service: APIService = DefaultAPIService(urlSession: .shared), dataManager: DataManager, updateIntervalProvider: @escaping UpdateIntervalProvider) {
+ self.init(apiClient: APIClient(environment: apiEnvironment, service: service), dataManager: dataManager, updateIntervalProvider: updateIntervalProvider)
+ }
+
+ init(apiClient: APIClient.Mockable, dataManager: DataManaging, sleeper: Sleeper = .default, updateIntervalProvider: @escaping UpdateIntervalProvider) {
+ self.apiClient = apiClient
+ self.dataManager = dataManager
+ self.updateIntervalProvider = updateIntervalProvider
+ self.sleeper = sleeper
+ }
+
+ func updateData(for key: DataKey) async {
+ // load currently stored data set
+ var dataSet = await dataManager.dataSet(for: key)
+ let oldRevision = dataSet.revision
+
+ // get change set from current revision from API
+ let changeSet: APIClient.ChangeSetResponse
+ do {
+ let request = DataKey.DataSet.APIRequest(threatKind: key.threatKind, revision: oldRevision)
+ changeSet = try await apiClient.load(request)
+ } catch {
+ Logger.updateManager.error("error fetching filter set: \(error)")
+ return
+ }
+ guard !changeSet.isEmpty || changeSet.revision != dataSet.revision else {
+ Logger.updateManager.debug("no changes to filter set")
+ return
+ }
+
+ // apply changes
+ dataSet.apply(changeSet)
+
+ // store back
+ await self.dataManager.store(dataSet, for: key)
+ Logger.updateManager.debug("\(type(of: key)).\(key.threatKind) updated from rev.\(oldRevision) to rev.\(dataSet.revision)")
+ }
+
+ public func startPeriodicUpdates() -> Task {
+ Task.detached {
+ // run update jobs in background for every data type
+ try await withThrowingTaskGroup(of: Never.self) { group in
+ for dataType in DataManager.StoredDataType.allCases {
+ // get update interval from provider
+ guard let updateInterval = updateIntervalProvider(dataType) else { continue }
+ guard updateInterval > 0 else {
+ assertionFailure("Update interval for \(dataType) must be positive")
+ continue
+ }
+
+ group.addTask {
+ // run periodically until the parent task is cancelled
+ try await performPeriodicJob(interval: updateInterval, sleeper: sleeper) {
+ await self.updateData(for: dataType.dataKey)
+ }
+ }
+ }
+ for try await _ in group {}
+ }
+ }
+ }
+
+}
diff --git a/Sources/Navigation/Extensions/WKErrorExtension.swift b/Sources/Navigation/Extensions/WKErrorExtension.swift
index f1a5c238d..de750e766 100644
--- a/Sources/Navigation/Extensions/WKErrorExtension.swift
+++ b/Sources/Navigation/Extensions/WKErrorExtension.swift
@@ -33,6 +33,14 @@ extension WKError {
code.rawValue == NSURLErrorCancelled && _nsError.domain == NSURLErrorDomain
}
+ public var isServerCertificateUntrusted: Bool {
+ _nsError.isServerCertificateUntrusted
+ }
+}
+extension NSError {
+ public var isServerCertificateUntrusted: Bool {
+ code == NSURLErrorServerCertificateUntrusted && domain == NSURLErrorDomain
+ }
}
extension WKError {
diff --git a/Sources/Networking/README.md b/Sources/Networking/README.md
index 83a2c5ce3..751ee63d8 100644
--- a/Sources/Networking/README.md
+++ b/Sources/Networking/README.md
@@ -19,7 +19,7 @@ let request = APIRequestV2(url: HTTPURLResponse.testUrl,
responseConstraints: [.allowHTTPNotModified,
.requireETagHeader,
.requireUserAgent],
- allowedQueryReservedCharacters: CharacterSet(charactersIn: ","))!
+ allowedQueryReservedCharacters: CharacterSet(charactersIn: ","))
let apiService = DefaultAPIService(urlSession: URLSession.shared)
```
@@ -55,12 +55,12 @@ The `MockPIService` implementing `APIService` can be found in `BSK/TestUtils`
```
let apiResponse = (Data(), HTTPURLResponse(url: HTTPURLResponse.testUrl,
- statusCode: 200,
- httpVersion: nil,
- headerFields: nil)!)
-let mockedAPIService = MockAPIService(decodableResponse: Result.failure(SomeError.testError), apiResponse: Result.success(apiResponse) )
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil)!)
+let mockedAPIService = MockAPIService(apiResponse: Result.success(apiResponse))
```
## v1 (Legacy)
-Not to be used. All V1 public functions have been deprecated and maintained only for backward compatibility.
\ No newline at end of file
+Not to be used. All V1 public functions have been deprecated and maintained only for backward compatibility.
diff --git a/Sources/Networking/v1/APIHeaders.swift b/Sources/Networking/v1/APIHeaders.swift
index 6d7f0a4b0..a5786c949 100644
--- a/Sources/Networking/v1/APIHeaders.swift
+++ b/Sources/Networking/v1/APIHeaders.swift
@@ -25,7 +25,7 @@ public extension APIRequest {
struct Headers {
public typealias UserAgent = String
- private static var userAgent: UserAgent?
+ public private(set) static var userAgent: UserAgent?
public static func setUserAgent(_ userAgent: UserAgent) {
self.userAgent = userAgent
}
diff --git a/Sources/Networking/v2/APIRequestV2.swift b/Sources/Networking/v2/APIRequestV2.swift
index 07434de67..a61604861 100644
--- a/Sources/Networking/v2/APIRequestV2.swift
+++ b/Sources/Networking/v2/APIRequestV2.swift
@@ -16,12 +16,11 @@
// limitations under the License.
//
+import Common
import Foundation
public struct APIRequestV2: CustomDebugStringConvertible {
- public typealias QueryItems = [String: String]
-
let timeoutInterval: TimeInterval
let responseConstraints: [APIResponseConstraints]?
public let urlRequest: URLRequest
@@ -37,25 +36,25 @@ public struct APIRequestV2: CustomDebugStringConvertible {
/// - cachePolicy: The request cache policy, default is `.useProtocolCachePolicy`
/// - responseRequirements: The response requirements
/// - allowedQueryReservedCharacters: The characters in this character set will not be URL encoded in the query parameters
- public init?(url: URL,
- method: HTTPRequestMethod = .get,
- queryItems: QueryItems? = nil,
- headers: APIRequestV2.HeadersV2? = APIRequestV2.HeadersV2(),
- body: Data? = nil,
- timeoutInterval: TimeInterval = 60.0,
- cachePolicy: URLRequest.CachePolicy? = nil,
- responseConstraints: [APIResponseConstraints]? = nil,
- allowedQueryReservedCharacters: CharacterSet? = nil) {
+ public init(
+ url: URL,
+ method: HTTPRequestMethod = .get,
+ queryItems: QueryParams?,
+ headers: APIRequestV2.HeadersV2? = APIRequestV2.HeadersV2(),
+ body: Data? = nil,
+ timeoutInterval: TimeInterval = 60.0,
+ cachePolicy: URLRequest.CachePolicy? = nil,
+ responseConstraints: [APIResponseConstraints]? = nil,
+ allowedQueryReservedCharacters: CharacterSet? = nil
+ ) where QueryParams.Element == (key: String, value: String) {
+
self.timeoutInterval = timeoutInterval
self.responseConstraints = responseConstraints
- // Generate URL request
- guard var urlComps = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
- return nil
- }
- urlComps.queryItems = queryItems?.toURLQueryItems(allowedReservedCharacters: allowedQueryReservedCharacters)
- guard let finalURL = urlComps.url else {
- return nil
+ let finalURL = if let queryItems {
+ url.appendingParameters(queryItems, allowedReservedCharacters: allowedQueryReservedCharacters)
+ } else {
+ url
}
var request = URLRequest(url: finalURL, timeoutInterval: timeoutInterval)
request.allHTTPHeaderFields = headers?.httpHeaders
@@ -67,6 +66,19 @@ public struct APIRequestV2: CustomDebugStringConvertible {
self.urlRequest = request
}
+ public init(
+ url: URL,
+ method: HTTPRequestMethod = .get,
+ headers: APIRequestV2.HeadersV2? = APIRequestV2.HeadersV2(),
+ body: Data? = nil,
+ timeoutInterval: TimeInterval = 60.0,
+ cachePolicy: URLRequest.CachePolicy? = nil,
+ responseConstraints: [APIResponseConstraints]? = nil,
+ allowedQueryReservedCharacters: CharacterSet? = nil
+ ) {
+ self.init(url: url, method: method, queryItems: [String: String]?.none, headers: headers, body: body, timeoutInterval: timeoutInterval, cachePolicy: cachePolicy, responseConstraints: responseConstraints, allowedQueryReservedCharacters: allowedQueryReservedCharacters)
+ }
+
public var debugDescription: String {
"""
APIRequestV2:
diff --git a/Sources/Networking/v2/APIResponseV2.swift b/Sources/Networking/v2/APIResponseV2.swift
index 1b178fd93..8987e377b 100644
--- a/Sources/Networking/v2/APIResponseV2.swift
+++ b/Sources/Networking/v2/APIResponseV2.swift
@@ -20,8 +20,13 @@ import Foundation
import os.log
public struct APIResponseV2 {
- let data: Data?
- let httpResponse: HTTPURLResponse
+ public let data: Data?
+ public let httpResponse: HTTPURLResponse
+
+ public init(data: Data?, httpResponse: HTTPURLResponse) {
+ self.data = data
+ self.httpResponse = httpResponse
+ }
}
public extension APIResponseV2 {
diff --git a/Sources/Onboarding/ContextualDaxDialogs/OnboardingSuggestionsViewModel.swift b/Sources/Onboarding/ContextualDaxDialogs/OnboardingSuggestionsViewModel.swift
index d10fecd56..ffff91188 100644
--- a/Sources/Onboarding/ContextualDaxDialogs/OnboardingSuggestionsViewModel.swift
+++ b/Sources/Onboarding/ContextualDaxDialogs/OnboardingSuggestionsViewModel.swift
@@ -19,8 +19,8 @@
import Foundation
public protocol OnboardingNavigationDelegate: AnyObject {
- func searchFor(_ query: String)
- func navigateTo(url: URL)
+ func searchFromOnboarding(for query: String)
+ func navigateFromOnboarding(to url: URL)
}
public protocol OnboardingSearchSuggestionsPixelReporting {
@@ -52,7 +52,7 @@ public struct OnboardingSearchSuggestionsViewModel {
public func listItemPressed(_ item: ContextualOnboardingListItem) {
pixelReporter.trackSearchSuggetionOptionTapped()
- delegate?.searchFor(item.title)
+ delegate?.searchFromOnboarding(for: item.title)
}
}
@@ -82,6 +82,6 @@ public struct OnboardingSiteSuggestionsViewModel {
public func listItemPressed(_ item: ContextualOnboardingListItem) {
guard let url = URL(string: item.title) else { return }
pixelReporter.trackSiteSuggetionOptionTapped()
- delegate?.navigateTo(url: url)
+ delegate?.navigateFromOnboarding(to: url)
}
}
diff --git a/Sources/PhishingDetection/Logger+PhishingDetection.swift b/Sources/PhishingDetection/Logger+PhishingDetection.swift
deleted file mode 100644
index 96a606772..000000000
--- a/Sources/PhishingDetection/Logger+PhishingDetection.swift
+++ /dev/null
@@ -1,29 +0,0 @@
-//
-// Logger+PhishingDetection.swift
-//
-// Copyright © 2023 DuckDuckGo. All rights reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-
-import Foundation
-import os
-
-public extension Logger {
- static var phishingDetection: Logger = { Logger(subsystem: "Phishing Detection", category: "") }()
- static var phishingDetectionClient: Logger = { Logger(subsystem: "Phishing Detection", category: "APIClient") }()
- static var phishingDetectionTasks: Logger = { Logger(subsystem: "Phishing Detection", category: "BackgroundActivities") }()
- static var phishingDetectionDataProvider: Logger = { Logger(subsystem: "Phishing Detection", category: "DataProvider") }()
- static var phishingDetectionDataStore: Logger = { Logger(subsystem: "Phishing Detection", category: "DataStore") }()
- static var phishingDetectionUpdateManager: Logger = { Logger(subsystem: "Phishing Detection", category: "UpdateManager") }()
-}
diff --git a/Sources/PhishingDetection/PhishingDetectionClient.swift b/Sources/PhishingDetection/PhishingDetectionClient.swift
deleted file mode 100644
index 942075b71..000000000
--- a/Sources/PhishingDetection/PhishingDetectionClient.swift
+++ /dev/null
@@ -1,177 +0,0 @@
-//
-// PhishingDetectionClient.swift
-//
-// Copyright © 2023 DuckDuckGo. All rights reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-
-import Foundation
-import Common
-import os
-
-public struct HashPrefixResponse: Codable, Equatable {
- public var insert: [String]
- public var delete: [String]
- public var revision: Int
- public var replace: Bool
-
- public init(insert: [String], delete: [String], revision: Int, replace: Bool) {
- self.insert = insert
- self.delete = delete
- self.revision = revision
- self.replace = replace
- }
-}
-
-public struct FilterSetResponse: Codable, Equatable {
- public var insert: [Filter]
- public var delete: [Filter]
- public var revision: Int
- public var replace: Bool
-
- public init(insert: [Filter], delete: [Filter], revision: Int, replace: Bool) {
- self.insert = insert
- self.delete = delete
- self.revision = revision
- self.replace = replace
- }
-}
-
-public struct MatchResponse: Codable, Equatable {
- public var matches: [Match]
-}
-
-public protocol PhishingDetectionClientProtocol {
- func getFilterSet(revision: Int) async -> FilterSetResponse
- func getHashPrefixes(revision: Int) async -> HashPrefixResponse
- func getMatches(hashPrefix: String) async -> [Match]
-}
-
-public protocol URLSessionProtocol {
- func data(for request: URLRequest) async throws -> (Data, URLResponse)
-}
-
-extension URLSession: URLSessionProtocol {}
-
-extension URLSessionProtocol {
- public static var defaultSession: URLSessionProtocol {
- return URLSession.shared
- }
-}
-
-public class PhishingDetectionAPIClient: PhishingDetectionClientProtocol {
-
- public enum Environment {
- case production
- case staging
- }
-
- enum Constants {
- static let productionEndpoint = URL(string: "https://duckduckgo.com/api/protection/")!
- static let stagingEndpoint = URL(string: "https://staging.duckduckgo.com/api/protection/")!
- enum APIPath: String {
- case filterSet
- case hashPrefix
- case matches
- }
- }
-
- private let endpointURL: URL
- private let session: URLSessionProtocol!
- private var headers: [String: String]? = [:]
-
- var filterSetURL: URL {
- endpointURL.appendingPathComponent(Constants.APIPath.filterSet.rawValue)
- }
-
- var hashPrefixURL: URL {
- endpointURL.appendingPathComponent(Constants.APIPath.hashPrefix.rawValue)
- }
-
- var matchesURL: URL {
- endpointURL.appendingPathComponent(Constants.APIPath.matches.rawValue)
- }
-
- public init(environment: Environment = .production, session: URLSessionProtocol = URLSession.defaultSession) {
- switch environment {
- case .production:
- endpointURL = Constants.productionEndpoint
- case .staging:
- endpointURL = Constants.stagingEndpoint
- }
- self.session = session
- }
-
- public func getFilterSet(revision: Int) async -> FilterSetResponse {
- guard let url = createURL(for: .filterSet, revision: revision) else {
- logDebug("🔸 Invalid filterSet revision URL: \(revision)")
- return FilterSetResponse(insert: [], delete: [], revision: revision, replace: false)
- }
- return await fetch(url: url, responseType: FilterSetResponse.self) ?? FilterSetResponse(insert: [], delete: [], revision: revision, replace: false)
- }
-
- public func getHashPrefixes(revision: Int) async -> HashPrefixResponse {
- guard let url = createURL(for: .hashPrefix, revision: revision) else {
- logDebug("🔸 Invalid hashPrefix revision URL: \(revision)")
- return HashPrefixResponse(insert: [], delete: [], revision: revision, replace: false)
- }
- return await fetch(url: url, responseType: HashPrefixResponse.self) ?? HashPrefixResponse(insert: [], delete: [], revision: revision, replace: false)
- }
-
- public func getMatches(hashPrefix: String) async -> [Match] {
- let queryItems = [URLQueryItem(name: "hashPrefix", value: hashPrefix)]
- guard let url = createURL(for: .matches, queryItems: queryItems) else {
- logDebug("🔸 Invalid matches URL: \(hashPrefix)")
- return []
- }
- return await fetch(url: url, responseType: MatchResponse.self)?.matches ?? []
- }
-}
-
-// MARK: Private Methods
-extension PhishingDetectionAPIClient {
-
- private func logDebug(_ message: String) {
- Logger.phishingDetectionClient.debug("\(message)")
- }
-
- private func createURL(for path: Constants.APIPath, revision: Int? = nil, queryItems: [URLQueryItem]? = nil) -> URL? {
- // Start with the base URL and append the path component
- var urlComponents = URLComponents(url: endpointURL.appendingPathComponent(path.rawValue), resolvingAgainstBaseURL: true)
- var items = queryItems ?? []
- if let revision = revision, revision > 0 {
- items.append(URLQueryItem(name: "revision", value: String(revision)))
- }
- urlComponents?.queryItems = items.isEmpty ? nil : items
- return urlComponents?.url
- }
-
- private func fetch(url: URL, responseType: T.Type) async -> T? {
- var request = URLRequest(url: url)
- request.httpMethod = "GET"
- request.allHTTPHeaderFields = headers
-
- do {
- let (data, _) = try await session.data(for: request)
- if let response = try? JSONDecoder().decode(responseType, from: data) {
- return response
- } else {
- logDebug("🔸 Failed to decode response for \(String(describing: responseType)): \(data)")
- }
- } catch {
- logDebug("🔴 Failed to load \(String(describing: responseType)) data: \(error)")
- }
- return nil
- }
-}
diff --git a/Sources/PhishingDetection/PhishingDetectionDataActivities.swift b/Sources/PhishingDetection/PhishingDetectionDataActivities.swift
deleted file mode 100644
index 3f195d75e..000000000
--- a/Sources/PhishingDetection/PhishingDetectionDataActivities.swift
+++ /dev/null
@@ -1,110 +0,0 @@
-//
-// PhishingDetectionDataActivities.swift
-//
-// Copyright © 2024 DuckDuckGo. All rights reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-
-import Foundation
-import Common
-import os
-
-public protocol BackgroundActivityScheduling: Actor {
- func start()
- func stop()
-}
-
-actor BackgroundActivityScheduler: BackgroundActivityScheduling {
-
- private var task: Task?
- private var timer: Timer?
- private let interval: TimeInterval
- private let identifier: String
- private let activity: () async -> Void
-
- init(interval: TimeInterval, identifier: String, activity: @escaping () async -> Void) {
- self.interval = interval
- self.identifier = identifier
- self.activity = activity
- }
-
- func start() {
- stop()
- task = Task {
- let taskId = UUID().uuidString
- while !Task.isCancelled {
- await activity()
- do {
- Logger.phishingDetectionTasks.debug("🟢 \(self.identifier) task was executed in instance \(taskId)")
- try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
- } catch {
- Logger.phishingDetectionTasks.error("🔴 Error \(self.identifier) task was cancelled before it could finish sleeping.")
- break
- }
- }
- }
- }
-
- func stop() {
- task?.cancel()
- task = nil
- }
-}
-
-public protocol PhishingDetectionDataActivityHandling {
- func start()
- func stop()
-}
-
-public class PhishingDetectionDataActivities: PhishingDetectionDataActivityHandling {
- private var schedulers: [BackgroundActivityScheduler]
- private var running: Bool = false
-
- var dataProvider: PhishingDetectionDataProviding
-
- public init(hashPrefixInterval: TimeInterval = 20 * 60, filterSetInterval: TimeInterval = 12 * 60 * 60, phishingDetectionDataProvider: PhishingDetectionDataProviding, updateManager: PhishingDetectionUpdateManaging) {
- let hashPrefixScheduler = BackgroundActivityScheduler(
- interval: hashPrefixInterval,
- identifier: "hashPrefixes.update",
- activity: { await updateManager.updateHashPrefixes() }
- )
- let filterSetScheduler = BackgroundActivityScheduler(
- interval: filterSetInterval,
- identifier: "filterSet.update",
- activity: { await updateManager.updateFilterSet() }
- )
- self.schedulers = [hashPrefixScheduler, filterSetScheduler]
- self.dataProvider = phishingDetectionDataProvider
- }
-
- public func start() {
- if !running {
- Task {
- for scheduler in schedulers {
- await scheduler.start()
- }
- }
- running = true
- }
- }
-
- public func stop() {
- Task {
- for scheduler in schedulers {
- await scheduler.stop()
- }
- }
- running = false
- }
-}
diff --git a/Sources/PhishingDetection/PhishingDetectionDataProvider.swift b/Sources/PhishingDetection/PhishingDetectionDataProvider.swift
deleted file mode 100644
index af1c87672..000000000
--- a/Sources/PhishingDetection/PhishingDetectionDataProvider.swift
+++ /dev/null
@@ -1,75 +0,0 @@
-//
-// PhishingDetectionDataProvider.swift
-//
-// Copyright © 2024 DuckDuckGo. All rights reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-
-import Foundation
-import CryptoKit
-import Common
-import os
-
-public protocol PhishingDetectionDataProviding {
- var embeddedRevision: Int { get }
- func loadEmbeddedFilterSet() -> Set
- func loadEmbeddedHashPrefixes() -> Set
-}
-
-public class PhishingDetectionDataProvider: PhishingDetectionDataProviding {
- public private(set) var embeddedRevision: Int
- var embeddedFilterSetURL: URL
- var embeddedFilterSetDataSHA: String
- var embeddedHashPrefixURL: URL
- var embeddedHashPrefixDataSHA: String
-
- public init(revision: Int, filterSetURL: URL, filterSetDataSHA: String, hashPrefixURL: URL, hashPrefixDataSHA: String) {
- embeddedFilterSetURL = filterSetURL
- embeddedFilterSetDataSHA = filterSetDataSHA
- embeddedHashPrefixURL = hashPrefixURL
- embeddedHashPrefixDataSHA = hashPrefixDataSHA
- embeddedRevision = revision
- }
-
- private func loadData(from url: URL, expectedSHA: String) throws -> Data {
- let data = try Data(contentsOf: url)
- let sha256 = SHA256.hash(data: data)
- let hashString = sha256.compactMap { String(format: "%02x", $0) }.joined()
-
- guard hashString == expectedSHA else {
- throw NSError(domain: "PhishingDetectionDataProvider", code: 1001, userInfo: [NSLocalizedDescriptionKey: "SHA mismatch"])
- }
- return data
- }
-
- public func loadEmbeddedFilterSet() -> Set {
- do {
- let filterSetData = try loadData(from: embeddedFilterSetURL, expectedSHA: embeddedFilterSetDataSHA)
- return try JSONDecoder().decode(Set.self, from: filterSetData)
- } catch {
- Logger.phishingDetectionDataProvider.error("🔴 Error: SHA mismatch for filterSet JSON file. Expected \(self.embeddedFilterSetDataSHA)")
- return []
- }
- }
-
- public func loadEmbeddedHashPrefixes() -> Set {
- do {
- let hashPrefixData = try loadData(from: embeddedHashPrefixURL, expectedSHA: embeddedHashPrefixDataSHA)
- return try JSONDecoder().decode(Set.self, from: hashPrefixData)
- } catch {
- Logger.phishingDetectionDataProvider.error("🔴 Error: SHA mismatch for hashPrefixes JSON file. Expected \(self.embeddedHashPrefixDataSHA)")
- return []
- }
- }
-}
diff --git a/Sources/PhishingDetection/PhishingDetectionDataStore.swift b/Sources/PhishingDetection/PhishingDetectionDataStore.swift
deleted file mode 100644
index f247f90b8..000000000
--- a/Sources/PhishingDetection/PhishingDetectionDataStore.swift
+++ /dev/null
@@ -1,266 +0,0 @@
-//
-// PhishingDetectionDataStore.swift
-//
-// Copyright © 2024 DuckDuckGo. All rights reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-
-import Foundation
-import Common
-import os
-
-enum PhishingDetectionDataError: Error {
- case empty
-}
-
-public struct Filter: Codable, Hashable {
- public var hashValue: String
- public var regex: String
-
- enum CodingKeys: String, CodingKey {
- case hashValue = "hash"
- case regex
- }
-
- public init(hashValue: String, regex: String) {
- self.hashValue = hashValue
- self.regex = regex
- }
-}
-
-public struct Match: Codable, Hashable {
- var hostname: String
- var url: String
- var regex: String
- var hash: String
-
- public init(hostname: String, url: String, regex: String, hash: String) {
- self.hostname = hostname
- self.url = url
- self.regex = regex
- self.hash = hash
- }
-}
-
-public protocol PhishingDetectionDataSaving {
- var filterSet: Set { get }
- var hashPrefixes: Set { get }
- var currentRevision: Int { get }
- func saveFilterSet(set: Set)
- func saveHashPrefixes(set: Set)
- func saveRevision(_ revision: Int)
-}
-
-public class PhishingDetectionDataStore: PhishingDetectionDataSaving {
- private lazy var _filterSet: Set = {
- loadFilterSet()
- }()
-
- private lazy var _hashPrefixes: Set = {
- loadHashPrefix()
- }()
-
- private lazy var _currentRevision: Int = {
- loadRevision()
- }()
-
- public private(set) var filterSet: Set {
- get { _filterSet }
- set { _filterSet = newValue }
- }
- public private(set) var hashPrefixes: Set {
- get { _hashPrefixes }
- set { _hashPrefixes = newValue }
- }
- public private(set) var currentRevision: Int {
- get { _currentRevision }
- set { _currentRevision = newValue }
- }
-
- private let dataProvider: PhishingDetectionDataProviding
- private let fileStorageManager: FileStorageManager
- private let encoder = JSONEncoder()
- private let revisionFilename = "revision.txt"
- private let hashPrefixFilename = "hashPrefixes.json"
- private let filterSetFilename = "filterSet.json"
-
- public init(dataProvider: PhishingDetectionDataProviding,
- fileStorageManager: FileStorageManager? = nil) {
- self.dataProvider = dataProvider
- if let injectedFileStorageManager = fileStorageManager {
- self.fileStorageManager = injectedFileStorageManager
- } else {
- self.fileStorageManager = PhishingFileStorageManager()
- }
- }
-
- private func writeHashPrefixes() {
- let encoder = JSONEncoder()
- do {
- let hashPrefixesData = try encoder.encode(Array(hashPrefixes))
- fileStorageManager.write(data: hashPrefixesData, to: hashPrefixFilename)
- } catch {
- Logger.phishingDetectionDataStore.error("Error saving hash prefixes data: \(error.localizedDescription)")
- }
- }
-
- private func writeFilterSet() {
- let encoder = JSONEncoder()
- do {
- let filterSetData = try encoder.encode(Array(filterSet))
- fileStorageManager.write(data: filterSetData, to: filterSetFilename)
- } catch {
- Logger.phishingDetectionDataStore.error("Error saving filter set data: \(error.localizedDescription)")
- }
- }
-
- private func writeRevision() {
- let encoder = JSONEncoder()
- do {
- let revisionData = try encoder.encode(currentRevision)
- fileStorageManager.write(data: revisionData, to: revisionFilename)
- } catch {
- Logger.phishingDetectionDataStore.error("Error saving revision data: \(error.localizedDescription)")
- }
- }
-
- private func loadHashPrefix() -> Set {
- guard let data = fileStorageManager.read(from: hashPrefixFilename) else {
- return dataProvider.loadEmbeddedHashPrefixes()
- }
- let decoder = JSONDecoder()
- do {
- if loadRevisionFromDisk() < dataProvider.embeddedRevision {
- return dataProvider.loadEmbeddedHashPrefixes()
- }
- let onDiskHashPrefixes = Set(try decoder.decode(Set.self, from: data))
- return onDiskHashPrefixes
- } catch {
- Logger.phishingDetectionDataStore.error("Error decoding \(self.hashPrefixFilename): \(error.localizedDescription)")
- return dataProvider.loadEmbeddedHashPrefixes()
- }
- }
-
- private func loadFilterSet() -> Set {
- guard let data = fileStorageManager.read(from: filterSetFilename) else {
- return dataProvider.loadEmbeddedFilterSet()
- }
- let decoder = JSONDecoder()
- do {
- if loadRevisionFromDisk() < dataProvider.embeddedRevision {
- return dataProvider.loadEmbeddedFilterSet()
- }
- let onDiskFilterSet = Set(try decoder.decode(Set.self, from: data))
- return onDiskFilterSet
- } catch {
- Logger.phishingDetectionDataStore.error("Error decoding \(self.filterSetFilename): \(error.localizedDescription)")
- return dataProvider.loadEmbeddedFilterSet()
- }
- }
-
- private func loadRevisionFromDisk() -> Int {
- guard let data = fileStorageManager.read(from: revisionFilename) else {
- return dataProvider.embeddedRevision
- }
- let decoder = JSONDecoder()
- do {
- return try decoder.decode(Int.self, from: data)
- } catch {
- Logger.phishingDetectionDataStore.error("Error decoding \(self.revisionFilename): \(error.localizedDescription)")
- return dataProvider.embeddedRevision
- }
- }
-
- private func loadRevision() -> Int {
- guard let data = fileStorageManager.read(from: revisionFilename) else {
- return dataProvider.embeddedRevision
- }
- let decoder = JSONDecoder()
- do {
- let loadedRevision = try decoder.decode(Int.self, from: data)
- if loadedRevision < dataProvider.embeddedRevision {
- return dataProvider.embeddedRevision
- }
- return loadedRevision
- } catch {
- Logger.phishingDetectionDataStore.error("Error decoding \(self.revisionFilename): \(error.localizedDescription)")
- return dataProvider.embeddedRevision
- }
- }
-}
-
-extension PhishingDetectionDataStore {
- public func saveFilterSet(set: Set) {
- self.filterSet = set
- writeFilterSet()
- }
-
- public func saveHashPrefixes(set: Set) {
- self.hashPrefixes = set
- writeHashPrefixes()
- }
-
- public func saveRevision(_ revision: Int) {
- self.currentRevision = revision
- writeRevision()
- }
-}
-
-public protocol FileStorageManager {
- func write(data: Data, to filename: String)
- func read(from filename: String) -> Data?
-}
-
-final class PhishingFileStorageManager: FileStorageManager {
- private let dataStoreURL: URL
-
- init() {
- let dataStoreDirectory: URL
- do {
- dataStoreDirectory = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
- } catch {
- Logger.phishingDetectionDataStore.error("Error accessing application support directory: \(error.localizedDescription)")
- dataStoreDirectory = FileManager.default.temporaryDirectory
- }
- dataStoreURL = dataStoreDirectory.appendingPathComponent(Bundle.main.bundleIdentifier!, isDirectory: true)
- createDirectoryIfNeeded()
- }
-
- private func createDirectoryIfNeeded() {
- do {
- try FileManager.default.createDirectory(at: dataStoreURL, withIntermediateDirectories: true, attributes: nil)
- } catch {
- Logger.phishingDetectionDataStore.error("Failed to create directory: \(error.localizedDescription)")
- }
- }
-
- func write(data: Data, to filename: String) {
- let fileURL = dataStoreURL.appendingPathComponent(filename)
- do {
- try data.write(to: fileURL)
- } catch {
- Logger.phishingDetectionDataStore.error("Error writing to directory: \(error.localizedDescription)")
- }
- }
-
- func read(from filename: String) -> Data? {
- let fileURL = dataStoreURL.appendingPathComponent(filename)
- do {
- return try Data(contentsOf: fileURL)
- } catch {
- Logger.phishingDetectionDataStore.error("Error accessing application support directory: \(error)")
- return nil
- }
- }
-}
diff --git a/Sources/PhishingDetection/PhishingDetectionUpdateManager.swift b/Sources/PhishingDetection/PhishingDetectionUpdateManager.swift
deleted file mode 100644
index b811082e3..000000000
--- a/Sources/PhishingDetection/PhishingDetectionUpdateManager.swift
+++ /dev/null
@@ -1,83 +0,0 @@
-//
-// PhishingDetectionUpdateManager.swift
-//
-// Copyright © 2023 DuckDuckGo. All rights reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-
-import Foundation
-import Common
-import os
-
-public protocol PhishingDetectionUpdateManaging {
- func updateFilterSet() async
- func updateHashPrefixes() async
-}
-
-public class PhishingDetectionUpdateManager: PhishingDetectionUpdateManaging {
- var apiClient: PhishingDetectionClientProtocol
- var dataStore: PhishingDetectionDataSaving
-
- public init(client: PhishingDetectionClientProtocol, dataStore: PhishingDetectionDataSaving) {
- self.apiClient = client
- self.dataStore = dataStore
- }
-
- private func updateSet(
- currentSet: Set,
- insert: [T],
- delete: [T],
- replace: Bool,
- saveSet: (Set) -> Void
- ) {
- var newSet = currentSet
-
- if replace {
- newSet = Set(insert)
- } else {
- newSet.formUnion(insert)
- newSet.subtract(delete)
- }
-
- saveSet(newSet)
- }
-
- public func updateFilterSet() async {
- let response = await apiClient.getFilterSet(revision: dataStore.currentRevision)
- updateSet(
- currentSet: dataStore.filterSet,
- insert: response.insert,
- delete: response.delete,
- replace: response.replace
- ) { newSet in
- self.dataStore.saveFilterSet(set: newSet)
- }
- dataStore.saveRevision(response.revision)
- Logger.phishingDetectionUpdateManager.debug("filterSet updated to revision \(self.dataStore.currentRevision)")
- }
-
- public func updateHashPrefixes() async {
- let response = await apiClient.getHashPrefixes(revision: dataStore.currentRevision)
- updateSet(
- currentSet: dataStore.hashPrefixes,
- insert: response.insert,
- delete: response.delete,
- replace: response.replace
- ) { newSet in
- self.dataStore.saveHashPrefixes(set: newSet)
- }
- dataStore.saveRevision(response.revision)
- Logger.phishingDetectionUpdateManager.debug("hashPrefixes updated to revision \(self.dataStore.currentRevision)")
- }
-}
diff --git a/Sources/PhishingDetection/PhishingDetector.swift b/Sources/PhishingDetection/PhishingDetector.swift
deleted file mode 100644
index 3ccbe9b7e..000000000
--- a/Sources/PhishingDetection/PhishingDetector.swift
+++ /dev/null
@@ -1,130 +0,0 @@
-//
-// PhishingDetector.swift
-//
-// Copyright © 2023 DuckDuckGo. All rights reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-
-import Foundation
-import CryptoKit
-import Common
-import WebKit
-
-public enum PhishingDetectionError: CustomNSError {
- case detected
-
- public static let errorDomain: String = "PhishingDetectionError"
-
- public var errorCode: Int {
- switch self {
- case .detected:
- return 1331
- }
- }
-
- public var errorUserInfo: [String: Any] {
- switch self {
- case .detected:
- return [NSLocalizedDescriptionKey: "Phishing detected"]
- }
- }
-
- public var rawValue: Int {
- return self.errorCode
- }
-}
-
-public protocol PhishingDetecting {
- func isMalicious(url: URL) async -> Bool
-}
-
-public class PhishingDetector: PhishingDetecting {
- let hashPrefixStoreLength: Int = 8
- let hashPrefixParamLength: Int = 4
- let apiClient: PhishingDetectionClientProtocol
- let dataStore: PhishingDetectionDataSaving
- let eventMapping: EventMapping
-
- public init(apiClient: PhishingDetectionClientProtocol, dataStore: PhishingDetectionDataSaving, eventMapping: EventMapping) {
- self.apiClient = apiClient
- self.dataStore = dataStore
- self.eventMapping = eventMapping
- }
-
- private func getMatches(hashPrefix: String) async -> Set {
- return Set(await apiClient.getMatches(hashPrefix: hashPrefix))
- }
-
- private func inFilterSet(hash: String) -> Set {
- return Set(dataStore.filterSet.filter { $0.hashValue == hash })
- }
-
- private func matchesUrl(hash: String, regexPattern: String, url: URL, hostnameHash: String) -> Bool {
- if hash == hostnameHash,
- let regex = try? NSRegularExpression(pattern: regexPattern, options: []) {
- let urlString = url.absoluteString
- let range = NSRange(location: 0, length: urlString.utf16.count)
- return regex.firstMatch(in: urlString, options: [], range: range) != nil
- }
- return false
- }
-
- private func generateHashPrefix(for canonicalHost: String, length: Int) -> String {
- let hostnameHash = SHA256.hash(data: Data(canonicalHost.utf8)).map { String(format: "%02hhx", $0) }.joined()
- return String(hostnameHash.prefix(length))
- }
-
- private func fetchMatches(hashPrefix: String) async -> [Match] {
- return await apiClient.getMatches(hashPrefix: hashPrefix)
- }
-
- private func checkLocalFilters(canonicalHost: String, canonicalUrl: URL) -> Bool {
- let hostnameHash = generateHashPrefix(for: canonicalHost, length: Int.max)
- let filterHit = inFilterSet(hash: hostnameHash)
- for filter in filterHit where matchesUrl(hash: filter.hashValue, regexPattern: filter.regex, url: canonicalUrl, hostnameHash: hostnameHash) {
- eventMapping.fire(PhishingDetectionEvents.errorPageShown(clientSideHit: true))
- return true
- }
- return false
- }
-
- private func checkApiMatches(canonicalHost: String, canonicalUrl: URL) async -> Bool {
- let hashPrefixParam = generateHashPrefix(for: canonicalHost, length: hashPrefixParamLength)
- let matches = await fetchMatches(hashPrefix: hashPrefixParam)
- let hostnameHash = generateHashPrefix(for: canonicalHost, length: Int.max)
- for match in matches where matchesUrl(hash: match.hash, regexPattern: match.regex, url: canonicalUrl, hostnameHash: hostnameHash) {
- eventMapping.fire(PhishingDetectionEvents.errorPageShown(clientSideHit: false))
- return true
- }
- return false
- }
-
- public func isMalicious(url: URL) async -> Bool {
- guard let canonicalHost = url.canonicalHost(), let canonicalUrl = url.canonicalURL() else { return false }
-
- let hashPrefix = generateHashPrefix(for: canonicalHost, length: hashPrefixStoreLength)
- if dataStore.hashPrefixes.contains(hashPrefix) {
- // Check local filterSet first
- if checkLocalFilters(canonicalHost: canonicalHost, canonicalUrl: canonicalUrl) {
- return true
- }
- // If nothing found, hit the API to get matches
- if await checkApiMatches(canonicalHost: canonicalHost, canonicalUrl: canonicalUrl) {
- return true
- }
- }
-
- return false
- }
-}
diff --git a/Sources/PrivacyDashboard/PrivacyDashboardController.swift b/Sources/PrivacyDashboard/PrivacyDashboardController.swift
index 8093a02d2..988c4cd20 100644
--- a/Sources/PrivacyDashboard/PrivacyDashboardController.swift
+++ b/Sources/PrivacyDashboard/PrivacyDashboardController.swift
@@ -16,12 +16,13 @@
// limitations under the License.
//
-import Foundation
-import WebKit
-import Combine
-import PrivacyDashboardResources
import BrowserServicesKit
+import Combine
import Common
+import Foundation
+import MaliciousSiteProtection
+import PrivacyDashboardResources
+import WebKit
public enum PrivacyDashboardOpenSettingsTarget: String {
@@ -205,7 +206,7 @@ extension PrivacyDashboardController: WKNavigationDelegate {
subscribeToServerTrust()
subscribeToConsentManaged()
subscribeToAllowedPermissions()
- subscribeToIsPhishing()
+ subscribeToMaliciousSiteThreatKind()
}
private func subscribeToTheme() {
@@ -259,12 +260,12 @@ extension PrivacyDashboardController: WKNavigationDelegate {
.store(in: &cancellables)
}
- private func subscribeToIsPhishing() {
- privacyInfo?.$isPhishing
+ private func subscribeToMaliciousSiteThreatKind() {
+ privacyInfo?.$malicousSiteThreatKind
.receive(on: DispatchQueue.main )
- .sink(receiveValue: { [weak self] isPhishing in
- guard let self = self, let webView = self.webView else { return }
- script.setIsPhishing(isPhishing, webView: webView)
+ .sink(receiveValue: { [weak self] detectedThreatKind in
+ guard let self, let webView else { return }
+ script.setMaliciousSiteDetectedThreatKind(detectedThreatKind, webView: webView)
})
.store(in: &cancellables)
}
diff --git a/Sources/PrivacyDashboard/PrivacyDashboardUserScript.swift b/Sources/PrivacyDashboard/PrivacyDashboardUserScript.swift
index 23fc5e738..801cdd81c 100644
--- a/Sources/PrivacyDashboard/PrivacyDashboardUserScript.swift
+++ b/Sources/PrivacyDashboard/PrivacyDashboardUserScript.swift
@@ -16,12 +16,13 @@
// limitations under the License.
//
+import BrowserServicesKit
+import Common
import Foundation
-import WebKit
+import MaliciousSiteProtection
import TrackerRadarKit
import UserScript
-import Common
-import BrowserServicesKit
+import WebKit
@MainActor
protocol PrivacyDashboardUserScriptDelegate: AnyObject {
@@ -425,8 +426,8 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript {
evaluate(js: "window.onChangeCertificateData(\(certificateDataJson))", in: webView)
}
- func setIsPhishing(_ isPhishing: Bool, webView: WKWebView) {
- let phishingStatus = ["phishingStatus": isPhishing]
+ func setMaliciousSiteDetectedThreatKind(_ detectedThreatKind: MaliciousSiteProtection.ThreatKind?, webView: WKWebView) {
+ let phishingStatus = ["phishingStatus": detectedThreatKind == .phishing]
guard let phishingStatusJson = try? JSONEncoder().encode(phishingStatus).utf8String() else {
assertionFailure("Can't encode phishingStatus into JSON")
return
diff --git a/Sources/PrivacyDashboard/PrivacyInfo.swift b/Sources/PrivacyDashboard/PrivacyInfo.swift
index b9db906fc..3eaabc185 100644
--- a/Sources/PrivacyDashboard/PrivacyInfo.swift
+++ b/Sources/PrivacyDashboard/PrivacyInfo.swift
@@ -16,9 +16,10 @@
// limitations under the License.
//
+import Common
import Foundation
+import MaliciousSiteProtection
import TrackerRadarKit
-import Common
public protocol SecurityTrust { }
extension SecTrust: SecurityTrust {}
@@ -33,15 +34,15 @@ public final class PrivacyInfo {
@Published public var serverTrust: SecurityTrust?
@Published public var connectionUpgradedTo: URL?
@Published public var cookieConsentManaged: CookieConsentInfo?
- @Published public var isPhishing: Bool
+ @Published public var malicousSiteThreatKind: MaliciousSiteProtection.ThreatKind?
@Published public var isSpecialErrorPageVisible: Bool = false
@Published public var shouldCheckServerTrust: Bool
- public init(url: URL, parentEntity: Entity?, protectionStatus: ProtectionStatus, isPhishing: Bool = false, shouldCheckServerTrust: Bool = false) {
+ public init(url: URL, parentEntity: Entity?, protectionStatus: ProtectionStatus, malicousSiteThreatKind: MaliciousSiteProtection.ThreatKind? = .none, shouldCheckServerTrust: Bool = false) {
self.url = url
self.parentEntity = parentEntity
self.protectionStatus = protectionStatus
- self.isPhishing = isPhishing
+ self.malicousSiteThreatKind = malicousSiteThreatKind
self.shouldCheckServerTrust = shouldCheckServerTrust
trackerInfo = TrackerInfo()
diff --git a/Sources/SpecialErrorPages/SSLErrorType.swift b/Sources/SpecialErrorPages/SSLErrorType.swift
index cf483b8e2..58137a293 100644
--- a/Sources/SpecialErrorPages/SSLErrorType.swift
+++ b/Sources/SpecialErrorPages/SSLErrorType.swift
@@ -17,28 +17,27 @@
//
import Foundation
+import WebKit
-public enum SSLErrorType: String {
+public let SSLErrorCodeKey = "_kCFStreamErrorCodeKey"
+
+public enum SSLErrorType: String, Encodable {
case expired
- case wrongHost
case selfSigned
+ case wrongHost
case invalid
- public static func forErrorCode(_ errorCode: Int) -> Self {
- switch Int32(errorCode) {
- case errSSLCertExpired:
- return .expired
- case errSSLHostNameMismatch:
- return .wrongHost
- case errSSLXCertChainInvalid:
- return .selfSigned
- default:
- return .invalid
+ init(errorCode: Int32) {
+ self = switch errorCode {
+ case errSSLCertExpired: .expired
+ case errSSLXCertChainInvalid: .selfSigned
+ case errSSLHostNameMismatch: .wrongHost
+ default: .invalid
}
}
- public var rawParameter: String {
+ public var pixelParameter: String {
switch self {
case .expired: return "expired"
case .wrongHost: return "wrong_host"
@@ -48,3 +47,16 @@ public enum SSLErrorType: String {
}
}
+
+extension WKError {
+ public var sslErrorType: SSLErrorType? {
+ _nsError.sslErrorType
+ }
+}
+extension NSError {
+ public var sslErrorType: SSLErrorType? {
+ guard let errorCode = self.userInfo[SSLErrorCodeKey] as? Int32 else { return nil }
+ let sslErrorType = SSLErrorType(errorCode: errorCode)
+ return sslErrorType
+ }
+}
diff --git a/Sources/SpecialErrorPages/SpecialErrorData.swift b/Sources/SpecialErrorPages/SpecialErrorData.swift
index 7ceb0baef..048077847 100644
--- a/Sources/SpecialErrorPages/SpecialErrorData.swift
+++ b/Sources/SpecialErrorPages/SpecialErrorData.swift
@@ -17,24 +17,61 @@
//
import Foundation
+import MaliciousSiteProtection
public enum SpecialErrorKind: String, Encodable {
case ssl
case phishing
+ // case malware
}
-public struct SpecialErrorData: Encodable, Equatable {
+public enum SpecialErrorData: Encodable, Equatable {
- var kind: SpecialErrorKind
- var errorType: String?
- var domain: String?
- var eTldPlus1: String?
+ enum CodingKeys: CodingKey {
+ case kind
+ case errorType
+ case domain
+ case eTldPlus1
+ case url
+ }
+
+ case ssl(type: SSLErrorType, domain: String, eTldPlus1: String?)
+ case maliciousSite(kind: MaliciousSiteProtection.ThreatKind, url: URL)
+
+ public func encode(to encoder: any Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+
+ switch self {
+ case .ssl(type: let type, domain: let domain, eTldPlus1: let eTldPlus1):
+ try container.encode(SpecialErrorKind.ssl, forKey: .kind)
+ try container.encode(type, forKey: .errorType)
+ try container.encode(domain, forKey: .domain)
- public init(kind: SpecialErrorKind, errorType: String? = nil, domain: String? = nil, eTldPlus1: String? = nil) {
- self.kind = kind
- self.errorType = errorType
- self.domain = domain
- self.eTldPlus1 = eTldPlus1
+ switch type {
+ case .expired, .selfSigned, .invalid: break
+ case .wrongHost:
+ guard let eTldPlus1 else {
+ assertionFailure("expected eTldPlus1 != nil when kind is .wrongHost")
+ break
+ }
+ try container.encode(eTldPlus1, forKey: .eTldPlus1)
+ }
+
+ case .maliciousSite(kind: let kind, url: let url):
+ // https://app.asana.com/0/1206594217596623/1208824527069247/f
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(kind.errorPageKind, forKey: .kind)
+ try container.encode(url, forKey: .url)
+ }
}
}
+
+public extension MaliciousSiteProtection.ThreatKind {
+ var errorPageKind: SpecialErrorKind {
+ switch self {
+ // case .malware: .malware
+ case .phishing: .phishing
+ }
+ }
+}
diff --git a/Sources/SpecialErrorPages/SpecialErrorPageUserScript.swift b/Sources/SpecialErrorPages/SpecialErrorPageUserScript.swift
index 71cad64e8..f131358ef 100644
--- a/Sources/SpecialErrorPages/SpecialErrorPageUserScript.swift
+++ b/Sources/SpecialErrorPages/SpecialErrorPageUserScript.swift
@@ -23,11 +23,11 @@ import Common
public protocol SpecialErrorPageUserScriptDelegate: AnyObject {
- var errorData: SpecialErrorData? { get }
+ @MainActor var errorData: SpecialErrorData? { get }
- func leaveSite()
- func visitSite()
- func advancedInfoPresented()
+ @MainActor func leaveSiteAction()
+ @MainActor func visitSiteAction()
+ @MainActor func advancedInfoPresented()
}
@@ -105,13 +105,13 @@ public final class SpecialErrorPageUserScript: NSObject, Subfeature {
@MainActor
func handleLeaveSiteAction(params: Any, message: UserScriptMessage) -> Encodable? {
- delegate?.leaveSite()
+ delegate?.leaveSiteAction()
return nil
}
@MainActor
func handleVisitSiteAction(params: Any, message: UserScriptMessage) -> Encodable? {
- delegate?.visitSite()
+ delegate?.visitSiteAction()
return nil
}
diff --git a/Sources/TestUtils/MockAPIService.swift b/Sources/TestUtils/MockAPIService.swift
index be4bf47a2..f4d35b4b6 100644
--- a/Sources/TestUtils/MockAPIService.swift
+++ b/Sources/TestUtils/MockAPIService.swift
@@ -19,12 +19,16 @@
import Foundation
import Networking
-public struct MockAPIService: APIService {
+public class MockAPIService: APIService {
- public var apiResponse: Result
+ public var requestHandler: ((APIRequestV2) -> Result)!
- public func fetch(request: Networking.APIRequestV2) async throws -> APIResponseV2 {
- switch apiResponse {
+ public init(requestHandler: ((APIRequestV2) -> Result)? = nil) {
+ self.requestHandler = requestHandler
+ }
+
+ public func fetch(request: APIRequestV2) async throws -> APIResponseV2 {
+ switch requestHandler!(request) {
case .success(let result):
return result
case .failure(let error):
diff --git a/Sources/UserScript/UserScript.swift b/Sources/UserScript/UserScript.swift
index 3b35ddc42..728b3b36a 100644
--- a/Sources/UserScript/UserScript.swift
+++ b/Sources/UserScript/UserScript.swift
@@ -107,7 +107,7 @@ extension UserScript {
}
public func makeWKUserScript() async -> WKUserScriptBox {
- let source = (try? await Task.detached { [source] in Self.prepareScriptSource(from: source) }.result.get())!
+ let source = await Task.detached { [source] in Self.prepareScriptSource(from: source) }.result.get()
return await Self.makeWKUserScript(from: source,
injectionTime: injectionTime,
forMainFrameOnly: forMainFrameOnly,
diff --git a/Tests/BrowserServicesKitTests/ContentBlocker/ContentBlockerRulesManagerInitialCompilationTests.swift b/Tests/BrowserServicesKitTests/ContentBlocker/ContentBlockerRulesManagerInitialCompilationTests.swift
index cb58c8c0d..455734d9b 100644
--- a/Tests/BrowserServicesKitTests/ContentBlocker/ContentBlockerRulesManagerInitialCompilationTests.swift
+++ b/Tests/BrowserServicesKitTests/ContentBlocker/ContentBlockerRulesManagerInitialCompilationTests.swift
@@ -26,7 +26,7 @@ import WebKit
import XCTest
import Common
-final class CountedFulfillmentTestExpectation: XCTestExpectation {
+final class CountedFulfillmentTestExpectation: XCTestExpectation, @unchecked Sendable {
private(set) var currentFulfillmentCount: Int = 0
override func fulfill() {
diff --git a/Tests/CommonTests/Extensions/StringExtensionTests.swift b/Tests/CommonTests/Extensions/StringExtensionTests.swift
index bcf415895..65abb79c8 100644
--- a/Tests/CommonTests/Extensions/StringExtensionTests.swift
+++ b/Tests/CommonTests/Extensions/StringExtensionTests.swift
@@ -16,8 +16,10 @@
// limitations under the License.
//
+import CryptoKit
import Foundation
import XCTest
+
@testable import Common
final class StringExtensionTests: XCTestCase {
@@ -370,4 +372,13 @@ final class StringExtensionTests: XCTestCase {
}
}
+ func testSha256() {
+ let string = "Hello, World! This is a test string."
+ let hash = string.sha256
+ let expected = "3c2b805ab0038afb0629e1d598ae73e0caabb69de03e96762977d34e8ba428bf"
+ let expectedSHA256 = SHA256.hash(data: Data(string.utf8)).map { String(format: "%02hhx", $0) }.joined()
+ XCTAssertEqual(hash, expected)
+ XCTAssertEqual(hash, expectedSHA256)
+ }
+
}
diff --git a/Tests/MaliciousSiteProtectionTests/MaliciousSiteDetectorTests.swift b/Tests/MaliciousSiteProtectionTests/MaliciousSiteDetectorTests.swift
new file mode 100644
index 000000000..f6b0de23a
--- /dev/null
+++ b/Tests/MaliciousSiteProtectionTests/MaliciousSiteDetectorTests.swift
@@ -0,0 +1,103 @@
+//
+// MaliciousSiteDetectorTests.swift
+//
+// Copyright © 2024 DuckDuckGo. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+import Foundation
+import XCTest
+
+@testable import MaliciousSiteProtection
+
+class MaliciousSiteDetectorTests: XCTestCase {
+
+ private var mockAPIClient: MockMaliciousSiteProtectionAPIClient!
+ private var mockDataManager: MockMaliciousSiteProtectionDataManager!
+ private var mockEventMapping: MockEventMapping!
+ private var detector: MaliciousSiteDetector!
+
+ override func setUp() async throws {
+ mockAPIClient = MockMaliciousSiteProtectionAPIClient()
+ mockDataManager = MockMaliciousSiteProtectionDataManager()
+ mockEventMapping = MockEventMapping()
+ detector = MaliciousSiteDetector(apiClient: mockAPIClient, dataManager: mockDataManager, eventMapping: mockEventMapping)
+ }
+
+ override func tearDown() async throws {
+ mockAPIClient = nil
+ mockDataManager = nil
+ mockEventMapping = nil
+ detector = nil
+ }
+
+ func testIsMaliciousWithLocalFilterHit() async {
+ let filter = Filter(hash: "255a8a793097aeea1f06a19c08cde28db0eb34c660c6e4e7480c9525d034b16d", regex: ".*malicious.*")
+ await mockDataManager.store(FilterDictionary(revision: 0, items: [filter]), for: .filterSet(threatKind: .phishing))
+ await mockDataManager.store(HashPrefixSet(revision: 0, items: ["255a8a79"]), for: .hashPrefixes(threatKind: .phishing))
+
+ let url = URL(string: "https://malicious.com/")!
+
+ let result = await detector.evaluate(url)
+
+ XCTAssertEqual(result, .phishing)
+ }
+
+ func testIsMaliciousWithApiMatch() async {
+ await mockDataManager.store(FilterDictionary(revision: 0, items: []), for: .filterSet(threatKind: .phishing))
+ await mockDataManager.store(HashPrefixSet(revision: 0, items: ["a379a6f6"]), for: .hashPrefixes(threatKind: .phishing))
+
+ let url = URL(string: "https://example.com/mal")!
+
+ let result = await detector.evaluate(url)
+
+ XCTAssertEqual(result, .phishing)
+ }
+
+ func testIsMaliciousWithHashPrefixMatch() async {
+ let filter = Filter(hash: "notamatch", regex: ".*malicious.*")
+ await mockDataManager.store(FilterDictionary(revision: 0, items: [filter]), for: .filterSet(threatKind: .phishing))
+ await mockDataManager.store(HashPrefixSet(revision: 0, items: ["4c64eb24" /* matches safe.com */]), for: .hashPrefixes(threatKind: .phishing))
+
+ let url = URL(string: "https://safe.com")!
+
+ let result = await detector.evaluate(url)
+
+ XCTAssertNil(result)
+ }
+
+ func testIsMaliciousWithFullHashMatch() async {
+ // 4c64eb2468bcd3e113b37167e6b819aeccf550f974a6082ef17fb74ca68e823b
+ let filter = Filter(hash: "4c64eb2468bcd3e113b37167e6b819aeccf550f974a6082ef17fb74ca68e823b", regex: "https://safe.com/maliciousURI")
+ await mockDataManager.store(FilterDictionary(revision: 0, items: [filter]), for: .filterSet(threatKind: .phishing))
+ await mockDataManager.store(HashPrefixSet(revision: 0, items: ["4c64eb24"]), for: .hashPrefixes(threatKind: .phishing))
+
+ let url = URL(string: "https://safe.com")!
+
+ let result = await detector.evaluate(url)
+
+ XCTAssertNil(result)
+ }
+
+ func testIsMaliciousWithNoHashPrefixMatch() async {
+ let filter = Filter(hash: "testHash", regex: ".*malicious.*")
+ await mockDataManager.store(FilterDictionary(revision: 0, items: [filter]), for: .filterSet(threatKind: .phishing))
+ await mockDataManager.store(HashPrefixSet(revision: 0, items: ["testPrefix"]), for: .hashPrefixes(threatKind: .phishing))
+
+ let url = URL(string: "https://safe.com")!
+
+ let result = await detector.evaluate(url)
+
+ XCTAssertNil(result)
+ }
+}
diff --git a/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionAPIClientTests.swift b/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionAPIClientTests.swift
new file mode 100644
index 000000000..fcea80939
--- /dev/null
+++ b/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionAPIClientTests.swift
@@ -0,0 +1,143 @@
+//
+// MaliciousSiteProtectionAPIClientTests.swift
+//
+// Copyright © 2024 DuckDuckGo. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+import Foundation
+import Networking
+import TestUtils
+import XCTest
+
+@testable import MaliciousSiteProtection
+
+final class MaliciousSiteProtectionAPIClientTests: XCTestCase {
+
+ var mockService: MockAPIService!
+ var client: MaliciousSiteProtection.APIClient!
+
+ override func setUp() {
+ super.setUp()
+ mockService = MockAPIService()
+ client = .init(environment: MaliciousSiteDetector.APIEnvironment.staging, service: mockService)
+ }
+
+ override func tearDown() {
+ mockService = nil
+ client = nil
+ super.tearDown()
+ }
+
+ func testWhenPhishingFilterSetRequestedAndSucceeds_ChangeSetIsReturned() async throws {
+ // Given
+ let insertFilter = Filter(hash: "a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947", regex: ".")
+ let deleteFilter = Filter(hash: "6a929cd0b3ba4677eaedf1b2bdaf3ff89281cca94f688c83103bc9a676aea46d", regex: "(?i)^https?\\:\\/\\/[\\w\\-\\.]+(?:\\:(?:80|443))?")
+ let expectedResponse = APIClient.Response.FiltersChangeSet(insert: [insertFilter], delete: [deleteFilter], revision: 666, replace: false)
+ mockService.requestHandler = { [unowned self] in
+ XCTAssertEqual($0.urlRequest.url, client.environment.url(for: .filterSet(.init(threatKind: .phishing, revision: 666))))
+ let data = try? JSONEncoder().encode(expectedResponse)
+ let response = HTTPURLResponse(url: $0.urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
+ return .success(.init(data: data, httpResponse: response))
+ }
+
+ // When
+ let response = try await client.filtersChangeSet(for: .phishing, revision: 666)
+
+ // Then
+ XCTAssertEqual(response, expectedResponse)
+ }
+
+ func testWhenHashPrefixesRequestedAndSucceeds_ChangeSetIsReturned() async throws {
+ // Given
+ let expectedResponse = APIClient.Response.HashPrefixesChangeSet(insert: ["abc"], delete: ["def"], revision: 1, replace: false)
+ mockService.requestHandler = { [unowned self] in
+ XCTAssertEqual($0.urlRequest.url, client.environment.url(for: .hashPrefixSet(.init(threatKind: .phishing, revision: 1))))
+ let data = try? JSONEncoder().encode(expectedResponse)
+ let response = HTTPURLResponse(url: $0.urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
+ return .success(.init(data: data, httpResponse: response))
+ }
+
+ // When
+ let response = try await client.hashPrefixesChangeSet(for: .phishing, revision: 1)
+
+ // Then
+ XCTAssertEqual(response, expectedResponse)
+ }
+
+ func testWhenMatchesRequestedAndSucceeds_MatchesAreReturned() async throws {
+ // Given
+ let expectedResponse = APIClient.Response.Matches(matches: [Match(hostname: "example.com", url: "https://example.com/test", regex: ".", hash: "a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947", category: nil)])
+ mockService.requestHandler = { [unowned self] in
+ XCTAssertEqual($0.urlRequest.url, client.environment.url(for: .matches(.init(hashPrefix: "abc"))))
+ let data = try? JSONEncoder().encode(expectedResponse)
+ let response = HTTPURLResponse(url: $0.urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
+ return .success(.init(data: data, httpResponse: response))
+ }
+
+ // When
+ let response = try await client.matches(forHashPrefix: "abc")
+
+ // Then
+ XCTAssertEqual(response.matches, expectedResponse.matches)
+ }
+
+ func testWhenHashPrefixesRequestFails_ErrorThrown() async throws {
+ // Given
+ let invalidRevision = -1
+ mockService.requestHandler = {
+ // Simulate a failure or invalid request
+ let response = HTTPURLResponse(url: $0.urlRequest.url!, statusCode: 400, httpVersion: nil, headerFields: nil)!
+ return .success(.init(data: nil, httpResponse: response))
+ }
+
+ do {
+ let response = try await client.hashPrefixesChangeSet(for: .phishing, revision: invalidRevision)
+ XCTFail("Unexpected \(response) expected throw")
+ } catch {
+ }
+ }
+
+ func testWhenFilterSetRequestFails_ErrorThrown() async throws {
+ // Given
+ let invalidRevision = -1
+ mockService.requestHandler = {
+ // Simulate a failure or invalid request
+ let response = HTTPURLResponse(url: $0.urlRequest.url!, statusCode: 400, httpVersion: nil, headerFields: nil)!
+ return .success(.init(data: nil, httpResponse: response))
+ }
+
+ do {
+ let response = try await client.hashPrefixesChangeSet(for: .phishing, revision: invalidRevision)
+ XCTFail("Unexpected \(response) expected throw")
+ } catch {
+ }
+ }
+
+ func testWhenMatchesRequestFails_ErrorThrown() async throws {
+ // Given
+ let invalidHashPrefix = ""
+ mockService.requestHandler = {
+ // Simulate a failure or invalid request
+ let response = HTTPURLResponse(url: $0.urlRequest.url!, statusCode: 400, httpVersion: nil, headerFields: nil)!
+ return .success(.init(data: nil, httpResponse: response))
+ }
+
+ do {
+ let response = try await client.matches(forHashPrefix: invalidHashPrefix)
+ XCTFail("Unexpected \(response) expected throw")
+ } catch {
+ }
+ }
+
+}
diff --git a/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionDataManagerTests.swift b/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionDataManagerTests.swift
new file mode 100644
index 000000000..5164f78d3
--- /dev/null
+++ b/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionDataManagerTests.swift
@@ -0,0 +1,250 @@
+//
+// MaliciousSiteProtectionDataManagerTests.swift
+//
+// Copyright © 2024 DuckDuckGo. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+import XCTest
+
+@testable import MaliciousSiteProtection
+
+class MaliciousSiteProtectionDataManagerTests: XCTestCase {
+ var embeddedDataProvider: MockMaliciousSiteProtectionEmbeddedDataProvider!
+ enum Constants {
+ static let hashPrefixesFileName = "phishingHashPrefixes.json"
+ static let filterSetFileName = "phishingFilterSet.json"
+ }
+ let datasetFiles: [String] = [Constants.hashPrefixesFileName, Constants.filterSetFileName]
+ var dataManager: MaliciousSiteProtection.DataManager!
+ var fileStore: MaliciousSiteProtection.FileStoring!
+
+ override func setUp() async throws {
+ embeddedDataProvider = MockMaliciousSiteProtectionEmbeddedDataProvider()
+ fileStore = MockMaliciousSiteProtectionFileStore()
+ setUpDataManager()
+ }
+
+ func setUpDataManager() {
+ dataManager = MaliciousSiteProtection.DataManager(fileStore: fileStore, embeddedDataProvider: embeddedDataProvider, fileNameProvider: { dataType in
+ switch dataType {
+ case .filterSet: Constants.filterSetFileName
+ case .hashPrefixSet: Constants.hashPrefixesFileName
+ }
+ })
+ }
+
+ override func tearDown() async throws {
+ embeddedDataProvider = nil
+ dataManager = nil
+ }
+
+ func clearDatasets() {
+ for fileName in datasetFiles {
+ let emptyData = Data()
+ fileStore.write(data: emptyData, to: fileName)
+ }
+ }
+
+ func testWhenNoDataSavedThenProviderDataReturned() async {
+ clearDatasets()
+ let expectedFilterSet = Set([Filter(hash: "some", regex: "some")])
+ let expectedFilterDict = FilterDictionary(revision: 65, items: expectedFilterSet)
+ let expectedHashPrefix = Set(["sassa"])
+ embeddedDataProvider.filterSet = expectedFilterSet
+ embeddedDataProvider.hashPrefixes = expectedHashPrefix
+
+ let actualFilterSet = await dataManager.dataSet(for: .filterSet(threatKind: .phishing))
+ let actualHashPrefix = await dataManager.dataSet(for: .hashPrefixes(threatKind: .phishing))
+
+ XCTAssertEqual(actualFilterSet, expectedFilterDict)
+ XCTAssertEqual(actualHashPrefix.set, expectedHashPrefix)
+ }
+
+ func testWhenEmbeddedRevisionNewerThanOnDisk_ThenLoadEmbedded() async {
+ let encoder = JSONEncoder()
+ // On Disk Data Setup
+ let onDiskFilterSet = Set([Filter(hash: "other", regex: "other")])
+ let filterSetData = try! encoder.encode(Array(onDiskFilterSet))
+ let onDiskHashPrefix = Set(["faffa"])
+ let hashPrefixData = try! encoder.encode(Array(onDiskHashPrefix))
+ fileStore.write(data: filterSetData, to: Constants.filterSetFileName)
+ fileStore.write(data: hashPrefixData, to: Constants.hashPrefixesFileName)
+
+ // Embedded Data Setup
+ embeddedDataProvider.embeddedRevision = 5
+ let embeddedFilterSet = Set([Filter(hash: "some", regex: "some")])
+ let embeddedFilterDict = FilterDictionary(revision: 5, items: embeddedFilterSet)
+ let embeddedHashPrefix = Set(["sassa"])
+ embeddedDataProvider.filterSet = embeddedFilterSet
+ embeddedDataProvider.hashPrefixes = embeddedHashPrefix
+
+ let actualFilterSet = await dataManager.dataSet(for: .filterSet(threatKind: .phishing))
+ let actualHashPrefix = await dataManager.dataSet(for: .hashPrefixes(threatKind: .phishing))
+ let actualFilterSetRevision = actualFilterSet.revision
+ let actualHashPrefixRevision = actualFilterSet.revision
+
+ XCTAssertEqual(actualFilterSet, embeddedFilterDict)
+ XCTAssertEqual(actualHashPrefix.set, embeddedHashPrefix)
+ XCTAssertEqual(actualFilterSetRevision, 5)
+ XCTAssertEqual(actualHashPrefixRevision, 5)
+ }
+
+ func testWhenEmbeddedRevisionOlderThanOnDisk_ThenDontLoadEmbedded() async {
+ // On Disk Data Setup
+ let onDiskFilterDict = FilterDictionary(revision: 6, items: [Filter(hash: "other", regex: "other")])
+ let filterSetData = try! JSONEncoder().encode(onDiskFilterDict)
+ let onDiskHashPrefix = HashPrefixSet(revision: 6, items: ["faffa"])
+ let hashPrefixData = try! JSONEncoder().encode(onDiskHashPrefix)
+ fileStore.write(data: filterSetData, to: Constants.filterSetFileName)
+ fileStore.write(data: hashPrefixData, to: Constants.hashPrefixesFileName)
+
+ // Embedded Data Setup
+ embeddedDataProvider.embeddedRevision = 1
+ let embeddedFilterSet = Set([Filter(hash: "some", regex: "some")])
+ let embeddedHashPrefix = Set(["sassa"])
+ embeddedDataProvider.filterSet = embeddedFilterSet
+ embeddedDataProvider.hashPrefixes = embeddedHashPrefix
+
+ let actualFilterSet = await dataManager.dataSet(for: .filterSet(threatKind: .phishing))
+ let actualHashPrefix = await dataManager.dataSet(for: .hashPrefixes(threatKind: .phishing))
+ let actualFilterSetRevision = actualFilterSet.revision
+ let actualHashPrefixRevision = actualFilterSet.revision
+
+ XCTAssertEqual(actualFilterSet, onDiskFilterDict)
+ XCTAssertEqual(actualHashPrefix, onDiskHashPrefix)
+ XCTAssertEqual(actualFilterSetRevision, 6)
+ XCTAssertEqual(actualHashPrefixRevision, 6)
+ }
+
+ func testWhenStoredDataIsMalformed_ThenEmbeddedDataIsLoaded() async {
+ // On Disk Data Setup
+ fileStore.write(data: "fake".utf8data, to: Constants.filterSetFileName)
+ fileStore.write(data: "fake".utf8data, to: Constants.hashPrefixesFileName)
+
+ // Embedded Data Setup
+ embeddedDataProvider.embeddedRevision = 1
+ let embeddedFilterSet = Set([Filter(hash: "some", regex: "some")])
+ let embeddedFilterDict = FilterDictionary(revision: 1, items: embeddedFilterSet)
+ let embeddedHashPrefix = Set(["sassa"])
+ embeddedDataProvider.filterSet = embeddedFilterSet
+ embeddedDataProvider.hashPrefixes = embeddedHashPrefix
+
+ let actualFilterSet = await dataManager.dataSet(for: .filterSet(threatKind: .phishing))
+ let actualHashPrefix = await dataManager.dataSet(for: .hashPrefixes(threatKind: .phishing))
+ let actualFilterSetRevision = actualFilterSet.revision
+ let actualHashPrefixRevision = actualFilterSet.revision
+
+ XCTAssertEqual(actualFilterSet, embeddedFilterDict)
+ XCTAssertEqual(actualHashPrefix.set, embeddedHashPrefix)
+ XCTAssertEqual(actualFilterSetRevision, 1)
+ XCTAssertEqual(actualHashPrefixRevision, 1)
+ }
+
+ func testWriteAndLoadData() async {
+ // Get and write data
+ let expectedHashPrefixes = Set(["aabb"])
+ let expectedFilterSet = Set([Filter(hash: "dummyhash", regex: "dummyregex")])
+ let expectedRevision = 65
+
+ await dataManager.store(HashPrefixSet(revision: expectedRevision, items: expectedHashPrefixes), for: .hashPrefixes(threatKind: .phishing))
+ await dataManager.store(FilterDictionary(revision: expectedRevision, items: expectedFilterSet), for: .filterSet(threatKind: .phishing))
+
+ let actualFilterSet = await dataManager.dataSet(for: .filterSet(threatKind: .phishing))
+ let actualHashPrefix = await dataManager.dataSet(for: .hashPrefixes(threatKind: .phishing))
+ let actualFilterSetRevision = actualFilterSet.revision
+ let actualHashPrefixRevision = actualFilterSet.revision
+
+ XCTAssertEqual(actualFilterSet, FilterDictionary(revision: expectedRevision, items: expectedFilterSet))
+ XCTAssertEqual(actualHashPrefix.set, expectedHashPrefixes)
+ XCTAssertEqual(actualFilterSetRevision, 65)
+ XCTAssertEqual(actualHashPrefixRevision, 65)
+
+ // Test reloading data
+ setUpDataManager()
+
+ let reloadedFilterSet = await dataManager.dataSet(for: .filterSet(threatKind: .phishing))
+ let reloadedHashPrefix = await dataManager.dataSet(for: .hashPrefixes(threatKind: .phishing))
+ let reloadedFilterSetRevision = actualFilterSet.revision
+ let reloadedHashPrefixRevision = actualFilterSet.revision
+
+ XCTAssertEqual(reloadedFilterSet, FilterDictionary(revision: expectedRevision, items: expectedFilterSet))
+ XCTAssertEqual(reloadedHashPrefix.set, expectedHashPrefixes)
+ XCTAssertEqual(reloadedFilterSetRevision, 65)
+ XCTAssertEqual(reloadedHashPrefixRevision, 65)
+ }
+
+ func testLazyLoadingDoesNotReturnStaleData() async {
+ clearDatasets()
+
+ // Set up initial data
+ let initialFilterSet = Set([Filter(hash: "initial", regex: "initial")])
+ let initialHashPrefixes = Set(["initialPrefix"])
+ embeddedDataProvider.filterSet = initialFilterSet
+ embeddedDataProvider.hashPrefixes = initialHashPrefixes
+
+ // Access the lazy-loaded properties to trigger loading
+ let loadedFilterSet = await dataManager.dataSet(for: .filterSet(threatKind: .phishing))
+ let loadedHashPrefixes = await dataManager.dataSet(for: .hashPrefixes(threatKind: .phishing))
+
+ // Validate loaded data matches initial data
+ XCTAssertEqual(loadedFilterSet, FilterDictionary(revision: 65, items: initialFilterSet))
+ XCTAssertEqual(loadedHashPrefixes.set, initialHashPrefixes)
+
+ // Update in-memory data
+ let updatedFilterSet = Set([Filter(hash: "updated", regex: "updated")])
+ let updatedHashPrefixes = Set(["updatedPrefix"])
+ await dataManager.store(HashPrefixSet(revision: 1, items: updatedHashPrefixes), for: .hashPrefixes(threatKind: .phishing))
+ await dataManager.store(FilterDictionary(revision: 1, items: updatedFilterSet), for: .filterSet(threatKind: .phishing))
+
+ let actualFilterSet = await dataManager.dataSet(for: .filterSet(threatKind: .phishing))
+ let actualHashPrefix = await dataManager.dataSet(for: .hashPrefixes(threatKind: .phishing))
+ let actualFilterSetRevision = actualFilterSet.revision
+ let actualHashPrefixRevision = actualFilterSet.revision
+
+ XCTAssertEqual(actualFilterSet, FilterDictionary(revision: 1, items: updatedFilterSet))
+ XCTAssertEqual(actualHashPrefix.set, updatedHashPrefixes)
+ XCTAssertEqual(actualFilterSetRevision, 1)
+ XCTAssertEqual(actualHashPrefixRevision, 1)
+
+ // Test reloading data – embedded data should be returned as its revision is greater
+ setUpDataManager()
+
+ let reloadedFilterSet = await dataManager.dataSet(for: .filterSet(threatKind: .phishing))
+ let reloadedHashPrefix = await dataManager.dataSet(for: .hashPrefixes(threatKind: .phishing))
+ let reloadedFilterSetRevision = actualFilterSet.revision
+ let reloadedHashPrefixRevision = actualFilterSet.revision
+
+ XCTAssertEqual(reloadedFilterSet, FilterDictionary(revision: 65, items: initialFilterSet))
+ XCTAssertEqual(reloadedHashPrefix.set, initialHashPrefixes)
+ XCTAssertEqual(reloadedFilterSetRevision, 1)
+ XCTAssertEqual(reloadedHashPrefixRevision, 1)
+ }
+
+}
+
+class MockMaliciousSiteProtectionFileStore: MaliciousSiteProtection.FileStoring {
+
+ private var data: [String: Data] = [:]
+
+ func write(data: Data, to filename: String) -> Bool {
+ self.data[filename] = data
+ return true
+ }
+
+ func read(from filename: String) -> Data? {
+ return data[filename]
+ }
+}
diff --git a/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionEmbeddedDataProviderTest.swift b/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionEmbeddedDataProviderTest.swift
new file mode 100644
index 000000000..1e3e0df40
--- /dev/null
+++ b/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionEmbeddedDataProviderTest.swift
@@ -0,0 +1,62 @@
+//
+// MaliciousSiteProtectionEmbeddedDataProviderTest.swift
+//
+// Copyright © 2024 DuckDuckGo. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+import Foundation
+import XCTest
+
+@testable import MaliciousSiteProtection
+
+class MaliciousSiteProtectionEmbeddedDataProviderTest: XCTestCase {
+
+ struct TestEmbeddedDataProvider: MaliciousSiteProtection.EmbeddedDataProviding {
+ func revision(for dataType: MaliciousSiteProtection.DataManager.StoredDataType) -> Int {
+ 0
+ }
+
+ func url(for dataType: MaliciousSiteProtection.DataManager.StoredDataType) -> URL {
+ switch dataType {
+ case .filterSet(let key):
+ Bundle.module.url(forResource: "\(key.threatKind)FilterSet", withExtension: "json")!
+ case .hashPrefixSet(let key):
+ Bundle.module.url(forResource: "\(key.threatKind)HashPrefixes", withExtension: "json")!
+ }
+ }
+
+ func hash(for dataType: MaliciousSiteProtection.DataManager.StoredDataType) -> String {
+ switch dataType {
+ case .filterSet(let key):
+ switch key.threatKind {
+ case .phishing:
+ "4fd2868a4f264501ec175ab866504a2a96c8d21a3b5195b405a4a83b51eae504"
+ }
+ case .hashPrefixSet(let key):
+ switch key.threatKind {
+ case .phishing:
+ "21b047a9950fcaf86034a6b16181e18815cb8d276386d85c8977ca8c5f8aa05f"
+ }
+ }
+ }
+ }
+
+ func testDataProviderLoadsJSON() {
+ let dataProvider = TestEmbeddedDataProvider()
+ let expectedFilter = Filter(hash: "e4753ddad954dafd4ff4ef67f82b3c1a2db6ef4a51bda43513260170e558bd13", regex: "(?i)^https?\\:\\/\\/privacy-test-pages\\.site(?:\\:(?:80|443))?\\/security\\/badware\\/phishing\\.html$")
+ XCTAssertTrue(dataProvider.loadDataSet(for: .filterSet(threatKind: .phishing)).contains(expectedFilter))
+ XCTAssertTrue(dataProvider.loadDataSet(for: .hashPrefixes(threatKind: .phishing)).contains("012db806"))
+ }
+
+}
diff --git a/Tests/PhishingDetectionTests/PhishingDetectionURLTests.swift b/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionURLTests.swift
similarity index 92%
rename from Tests/PhishingDetectionTests/PhishingDetectionURLTests.swift
rename to Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionURLTests.swift
index ea0576369..8df462b3e 100644
--- a/Tests/PhishingDetectionTests/PhishingDetectionURLTests.swift
+++ b/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionURLTests.swift
@@ -1,5 +1,5 @@
//
-// PhishingDetectionURLTests.swift
+// MaliciousSiteProtectionURLTests.swift
//
// Copyright © 2024 DuckDuckGo. All rights reserved.
//
@@ -18,9 +18,10 @@
import Foundation
import XCTest
-@testable import PhishingDetection
-class PhishingDetectionURLTests: XCTestCase {
+@testable import MaliciousSiteProtection
+
+class MaliciousSiteProtectionURLTests: XCTestCase {
let testURLs = [
"http://www.example.com/security/badware/phishing.html#frags",
diff --git a/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionUpdateManagerTests.swift b/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionUpdateManagerTests.swift
new file mode 100644
index 000000000..8d46d5cf7
--- /dev/null
+++ b/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionUpdateManagerTests.swift
@@ -0,0 +1,392 @@
+//
+// MaliciousSiteProtectionUpdateManagerTests.swift
+//
+// Copyright © 2024 DuckDuckGo. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Clocks
+import Common
+import Foundation
+import XCTest
+
+@testable import MaliciousSiteProtection
+
+class MaliciousSiteProtectionUpdateManagerTests: XCTestCase {
+
+ var updateManager: MaliciousSiteProtection.UpdateManager!
+ var dataManager: MockMaliciousSiteProtectionDataManager!
+ var apiClient: MaliciousSiteProtection.APIClient.Mockable!
+ var updateIntervalProvider: UpdateManager.UpdateIntervalProvider!
+ var clock: TestClock!
+ var willSleep: ((TimeInterval) -> Void)?
+ var updateTask: Task?
+
+ override func setUp() async throws {
+ apiClient = MockMaliciousSiteProtectionAPIClient()
+ dataManager = MockMaliciousSiteProtectionDataManager()
+ clock = TestClock()
+
+ let clockSleeper = Sleeper(clock: clock)
+ let reportingSleeper = Sleeper {
+ self.willSleep?($0)
+ try await clockSleeper.sleep(for: $0)
+ }
+
+ updateManager = MaliciousSiteProtection.UpdateManager(apiClient: apiClient, dataManager: dataManager, sleeper: reportingSleeper, updateIntervalProvider: { self.updateIntervalProvider($0) })
+ }
+
+ override func tearDown() async throws {
+ updateManager = nil
+ dataManager = nil
+ apiClient = nil
+ updateIntervalProvider = nil
+ updateTask?.cancel()
+ }
+
+ func testUpdateHashPrefixes() async {
+ await updateManager.updateData(for: .hashPrefixes(threatKind: .phishing))
+ let dataSet = await dataManager.dataSet(for: .hashPrefixes(threatKind: .phishing))
+ XCTAssertEqual(dataSet, HashPrefixSet(revision: 1, items: [
+ "aa00bb11",
+ "bb00cc11",
+ "cc00dd11",
+ "dd00ee11",
+ "a379a6f6"
+ ]))
+ }
+
+ func testUpdateFilterSet() async {
+ await updateManager.updateData(for: .filterSet(threatKind: .phishing))
+ let dataSet = await dataManager.dataSet(for: .filterSet(threatKind: .phishing))
+ XCTAssertEqual(dataSet, FilterDictionary(revision: 1, items: [
+ Filter(hash: "testhash1", regex: ".*example.*"),
+ Filter(hash: "testhash2", regex: ".*test.*")
+ ]))
+ }
+
+ func testRevision1AddsAndDeletesData() async {
+ let expectedFilterSet: Set = [
+ Filter(hash: "testhash2", regex: ".*test.*"),
+ Filter(hash: "testhash3", regex: ".*test.*")
+ ]
+ let expectedHashPrefixes: Set = [
+ "aa00bb11",
+ "bb00cc11",
+ "a379a6f6",
+ "93e2435e"
+ ]
+
+ // revision 0 -> 1
+ await updateManager.updateData(for: .filterSet(threatKind: .phishing))
+ await updateManager.updateData(for: .hashPrefixes(threatKind: .phishing))
+
+ // revision 1 -> 2
+ await updateManager.updateData(for: .filterSet(threatKind: .phishing))
+ await updateManager.updateData(for: .hashPrefixes(threatKind: .phishing))
+
+ let hashPrefixes = await dataManager.dataSet(for: .hashPrefixes(threatKind: .phishing))
+ let filterSet = await dataManager.dataSet(for: .filterSet(threatKind: .phishing))
+
+ XCTAssertEqual(hashPrefixes, HashPrefixSet(revision: 2, items: expectedHashPrefixes), "Hash prefixes should match the expected set after update.")
+ XCTAssertEqual(filterSet, FilterDictionary(revision: 2, items: expectedFilterSet), "Filter set should match the expected set after update.")
+ }
+
+ func testRevision2AddsAndDeletesData() async {
+ let expectedFilterSet: Set = [
+ Filter(hash: "testhash4", regex: ".*test.*"),
+ Filter(hash: "testhash2", regex: ".*test1.*"),
+ Filter(hash: "testhash1", regex: ".*example.*"),
+ Filter(hash: "testhash3", regex: ".*test3.*"),
+ ]
+ let expectedHashPrefixes: Set = [
+ "aa00bb11",
+ "a379a6f6",
+ "c0be0d0a6",
+ "dd00ee11",
+ "cc00dd11"
+ ]
+
+ // Save revision and update the filter set and hash prefixes
+ await dataManager.store(FilterDictionary(revision: 2, items: [
+ Filter(hash: "testhash1", regex: ".*example.*"),
+ Filter(hash: "testhash2", regex: ".*test.*"),
+ Filter(hash: "testhash2", regex: ".*test1.*"),
+ Filter(hash: "testhash3", regex: ".*test3.*"),
+ ]), for: .filterSet(threatKind: .phishing))
+ await dataManager.store(HashPrefixSet(revision: 2, items: [
+ "aa00bb11",
+ "bb00cc11",
+ "cc00dd11",
+ "dd00ee11",
+ "a379a6f6"
+ ]), for: .hashPrefixes(threatKind: .phishing))
+
+ await updateManager.updateData(for: .filterSet(threatKind: .phishing))
+ await updateManager.updateData(for: .hashPrefixes(threatKind: .phishing))
+
+ let hashPrefixes = await dataManager.dataSet(for: .hashPrefixes(threatKind: .phishing))
+ let filterSet = await dataManager.dataSet(for: .filterSet(threatKind: .phishing))
+
+ XCTAssertEqual(hashPrefixes, HashPrefixSet(revision: 3, items: expectedHashPrefixes), "Hash prefixes should match the expected set after update.")
+ XCTAssertEqual(filterSet, FilterDictionary(revision: 3, items: expectedFilterSet), "Filter set should match the expected set after update.")
+ }
+
+ func testRevision3AddsAndDeletesNothing() async {
+ let expectedFilterSet: Set = []
+ let expectedHashPrefixes: Set = []
+
+ // Save revision and update the filter set and hash prefixes
+ await dataManager.store(FilterDictionary(revision: 3, items: []), for: .filterSet(threatKind: .phishing))
+ await dataManager.store(HashPrefixSet(revision: 3, items: []), for: .hashPrefixes(threatKind: .phishing))
+
+ await updateManager.updateData(for: .filterSet(threatKind: .phishing))
+ await updateManager.updateData(for: .hashPrefixes(threatKind: .phishing))
+
+ let hashPrefixes = await dataManager.dataSet(for: .hashPrefixes(threatKind: .phishing))
+ let filterSet = await dataManager.dataSet(for: .filterSet(threatKind: .phishing))
+
+ XCTAssertEqual(hashPrefixes, HashPrefixSet(revision: 3, items: expectedHashPrefixes), "Hash prefixes should match the expected set after update.")
+ XCTAssertEqual(filterSet, FilterDictionary(revision: 3, items: expectedFilterSet), "Filter set should match the expected set after update.")
+ }
+
+ func testRevision4AddsAndDeletesData() async {
+ let expectedFilterSet: Set = [
+ Filter(hash: "testhash5", regex: ".*test.*")
+ ]
+ let expectedHashPrefixes: Set = [
+ "a379a6f6",
+ ]
+
+ // Save revision and update the filter set and hash prefixes
+ await dataManager.store(FilterDictionary(revision: 4, items: []), for: .filterSet(threatKind: .phishing))
+ await dataManager.store(HashPrefixSet(revision: 4, items: []), for: .hashPrefixes(threatKind: .phishing))
+
+ await updateManager.updateData(for: .filterSet(threatKind: .phishing))
+ await updateManager.updateData(for: .hashPrefixes(threatKind: .phishing))
+
+ let hashPrefixes = await dataManager.dataSet(for: .hashPrefixes(threatKind: .phishing))
+ let filterSet = await dataManager.dataSet(for: .filterSet(threatKind: .phishing))
+
+ XCTAssertEqual(hashPrefixes, HashPrefixSet(revision: 5, items: expectedHashPrefixes), "Hash prefixes should match the expected set after update.")
+ XCTAssertEqual(filterSet, FilterDictionary(revision: 5, items: expectedFilterSet), "Filter set should match the expected set after update.")
+ }
+
+ func testRevision5replacesData() async {
+ let expectedFilterSet: Set = [
+ Filter(hash: "testhash6", regex: ".*test6.*")
+ ]
+ let expectedHashPrefixes: Set = [
+ "aa55aa55"
+ ]
+
+ // Save revision and update the filter set and hash prefixes
+ await dataManager.store(FilterDictionary(revision: 5, items: [
+ Filter(hash: "testhash2", regex: ".*test.*"),
+ Filter(hash: "testhash1", regex: ".*example.*"),
+ Filter(hash: "testhash5", regex: ".*test.*")
+ ]), for: .filterSet(threatKind: .phishing))
+ await dataManager.store(HashPrefixSet(revision: 5, items: [
+ "a379a6f6",
+ "dd00ee11",
+ "cc00dd11",
+ "bb00cc11"
+ ]), for: .hashPrefixes(threatKind: .phishing))
+
+ await updateManager.updateData(for: .filterSet(threatKind: .phishing))
+ await updateManager.updateData(for: .hashPrefixes(threatKind: .phishing))
+
+ let hashPrefixes = await dataManager.dataSet(for: .hashPrefixes(threatKind: .phishing))
+ let filterSet = await dataManager.dataSet(for: .filterSet(threatKind: .phishing))
+
+ XCTAssertEqual(hashPrefixes, HashPrefixSet(revision: 6, items: expectedHashPrefixes), "Hash prefixes should match the expected set after update.")
+ XCTAssertEqual(filterSet, FilterDictionary(revision: 6, items: expectedFilterSet), "Filter set should match the expected set after update.")
+ }
+
+ func testWhenPeriodicUpdatesStart_dataSetsAreUpdated() async throws {
+ self.updateIntervalProvider = { _ in 1 }
+
+ let eHashPrefixesUpdated = expectation(description: "Hash prefixes updated")
+ let c1 = await dataManager.publisher(for: .hashPrefixes(threatKind: .phishing)).dropFirst().sink { data in
+ eHashPrefixesUpdated.fulfill()
+ }
+ let eFilterSetUpdated = expectation(description: "Filter set updated")
+ let c2 = await dataManager.publisher(for: .filterSet(threatKind: .phishing)).dropFirst().sink { data in
+ eFilterSetUpdated.fulfill()
+ }
+
+ updateTask = updateManager.startPeriodicUpdates()
+ await Task.megaYield(count: 10)
+
+ // expect initial update run instantly
+ await fulfillment(of: [eHashPrefixesUpdated, eFilterSetUpdated], timeout: 1)
+
+ withExtendedLifetime((c1, c2)) {}
+ }
+
+ func testWhenPeriodicUpdatesAreEnabled_dataSetsAreUpdatedContinuously() async throws {
+ // Start periodic updates
+ self.updateIntervalProvider = { dataType in
+ switch dataType {
+ case .filterSet: return 2
+ case .hashPrefixSet: return 1
+ }
+ }
+
+ let hashPrefixUpdateExpectations = [
+ XCTestExpectation(description: "Hash prefixes rev.1 update received"),
+ XCTestExpectation(description: "Hash prefixes rev.2 update received"),
+ XCTestExpectation(description: "Hash prefixes rev.3 update received"),
+ ]
+ let filterSetUpdateExpectations = [
+ XCTestExpectation(description: "Filter set rev.1 update received"),
+ XCTestExpectation(description: "Filter set rev.2 update received"),
+ XCTestExpectation(description: "Filter set rev.3 update received"),
+ ]
+ let hashPrefixSleepExpectations = [
+ XCTestExpectation(description: "HP Will Sleep 1"),
+ XCTestExpectation(description: "HP Will Sleep 2"),
+ XCTestExpectation(description: "HP Will Sleep 3"),
+ ]
+ let filterSetSleepExpectations = [
+ XCTestExpectation(description: "FS Will Sleep 1"),
+ XCTestExpectation(description: "FS Will Sleep 2"),
+ XCTestExpectation(description: "FS Will Sleep 3"),
+ ]
+
+ let c1 = await dataManager.publisher(for: .hashPrefixes(threatKind: .phishing)).dropFirst().sink { data in
+ hashPrefixUpdateExpectations[data.revision - 1].fulfill()
+ }
+ let c2 = await dataManager.publisher(for: .filterSet(threatKind: .phishing)).dropFirst().sink { data in
+ filterSetUpdateExpectations[data.revision - 1].fulfill()
+ }
+ var hashPrefixSleepIndex = 0
+ var filterSetSleepIndex = 0
+ self.willSleep = { interval in
+ if interval == 1 {
+ hashPrefixSleepExpectations[safe: hashPrefixSleepIndex]?.fulfill()
+ hashPrefixSleepIndex += 1
+ } else {
+ filterSetSleepExpectations[safe: filterSetSleepIndex]?.fulfill()
+ filterSetSleepIndex += 1
+ }
+ }
+
+ // expect initial hashPrefixes update run instantly
+ updateTask = updateManager.startPeriodicUpdates()
+ await fulfillment(of: [hashPrefixUpdateExpectations[0], hashPrefixSleepExpectations[0], filterSetUpdateExpectations[0], filterSetSleepExpectations[0]], timeout: 1)
+
+ // Advance the clock by 1 seconds
+ await self.clock.advance(by: .seconds(1))
+ // expect to receive v.2 update for hashPrefixes
+ await fulfillment(of: [hashPrefixUpdateExpectations[1], hashPrefixSleepExpectations[1]], timeout: 1)
+
+ // Advance the clock by 1 seconds
+ await self.clock.advance(by: .seconds(1))
+ // expect to receive v.3 update for hashPrefixes and v.2 update for filterSet
+ await fulfillment(of: [hashPrefixUpdateExpectations[2], hashPrefixSleepExpectations[2], filterSetUpdateExpectations[1], filterSetSleepExpectations[1]], timeout: 1) //
+
+ // Advance the clock by 1 seconds
+ await self.clock.advance(by: .seconds(2))
+ // expect to receive v.3 update for filterSet and no update for hashPrefixes (no v.3 updates in the mock)
+ await fulfillment(of: [filterSetUpdateExpectations[2], filterSetSleepExpectations[2]], timeout: 1) //
+
+ withExtendedLifetime((c1, c2)) {}
+ }
+
+ func testWhenPeriodicUpdatesAreDisabled_noDataSetsAreUpdated() async throws {
+ // Start periodic updates
+ self.updateIntervalProvider = { dataType in
+ switch dataType {
+ case .filterSet: return nil // Set update interval to nil for FilterSet
+ case .hashPrefixSet: return 1
+ }
+ }
+
+ let expectations = [
+ XCTestExpectation(description: "Hash prefixes rev.1 update received"),
+ XCTestExpectation(description: "Hash prefixes rev.2 update received"),
+ XCTestExpectation(description: "Hash prefixes rev.3 update received"),
+ ]
+ let c1 = await dataManager.publisher(for: .hashPrefixes(threatKind: .phishing)).dropFirst().sink { data in
+ expectations[data.revision - 1].fulfill()
+ }
+ // data for FilterSet should not be updated
+ let c2 = await dataManager.publisher(for: .filterSet(threatKind: .phishing)).dropFirst().sink { data in
+ XCTFail("Unexpected filter set update received: \(data)")
+ }
+ // synchronize Task threads to advance the Test Clock when the updated Task is sleeping,
+ // otherwise we‘ll eventually advance the clock before the sleep and get hung.
+ var sleepIndex = 0
+ let sleepExpectations = [
+ XCTestExpectation(description: "Will Sleep 1"),
+ XCTestExpectation(description: "Will Sleep 2"),
+ XCTestExpectation(description: "Will Sleep 3"),
+ ]
+ self.willSleep = { _ in
+ sleepExpectations[sleepIndex].fulfill()
+ sleepIndex += 1
+ }
+
+ // expect initial hashPrefixes update run instantly
+ updateTask = updateManager.startPeriodicUpdates()
+ await fulfillment(of: [expectations[0], sleepExpectations[0]], timeout: 1)
+
+ // Advance the clock by 1 seconds
+ await self.clock.advance(by: .seconds(1))
+ // expect to receive v.2 update for hashPrefixes
+ await fulfillment(of: [expectations[1], sleepExpectations[1]], timeout: 1)
+
+ // Advance the clock by 1 seconds
+ await self.clock.advance(by: .seconds(1))
+ // expect to receive v.3 update for hashPrefixes
+ await fulfillment(of: [expectations[2], sleepExpectations[2]], timeout: 1)
+
+ withExtendedLifetime((c1, c2)) {}
+ }
+
+ func testWhenPeriodicUpdatesAreCancelled_noFurtherUpdatesReceived() async throws {
+ // Start periodic updates
+ self.updateIntervalProvider = { _ in 1 }
+ updateTask = updateManager.startPeriodicUpdates()
+
+ // Wait for the initial update
+ try await withTimeout(1) { [self] in
+ for await _ in await dataManager.publisher(for: .filterSet(threatKind: .phishing)).first(where: { $0.revision == 1 }).values {}
+ for await _ in await dataManager.publisher(for: .filterSet(threatKind: .phishing)).first(where: { $0.revision == 1 }).values {}
+ }
+
+ // Cancel the update task
+ updateTask!.cancel()
+
+ // Reset expectations for further updates
+ let c = await dataManager.$store.dropFirst().sink { data in
+ XCTFail("Unexpected data update received: \(data)")
+ }
+
+ // Advance the clock to check for further updates
+ await self.clock.advance(by: .seconds(2))
+ await clock.run()
+ await Task.megaYield(count: 10)
+
+ // Verify that the data sets have not been updated further
+ let hashPrefixes = await dataManager.dataSet(for: .hashPrefixes(threatKind: .phishing))
+ let filterSet = await dataManager.dataSet(for: .filterSet(threatKind: .phishing))
+ XCTAssertEqual(hashPrefixes.revision, 1) // Expecting revision to remain 1
+ XCTAssertEqual(filterSet.revision, 1) // Expecting revision to remain 1
+
+ withExtendedLifetime(c) {}
+ }
+
+}
diff --git a/Tests/PhishingDetectionTests/Mocks/EventMappingMock.swift b/Tests/MaliciousSiteProtectionTests/Mocks/MockEventMapping.swift
similarity index 80%
rename from Tests/PhishingDetectionTests/Mocks/EventMappingMock.swift
rename to Tests/MaliciousSiteProtectionTests/Mocks/MockEventMapping.swift
index 7c736c7e3..1edbb98a2 100644
--- a/Tests/PhishingDetectionTests/Mocks/EventMappingMock.swift
+++ b/Tests/MaliciousSiteProtectionTests/Mocks/MockEventMapping.swift
@@ -1,5 +1,5 @@
//
-// EventMappingMock.swift
+// MockEventMapping.swift
//
// Copyright © 2024 DuckDuckGo. All rights reserved.
//
@@ -15,13 +15,14 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//
-import Foundation
+
import Common
-import PhishingDetection
+import Foundation
+import MaliciousSiteProtection
import PixelKit
-public class MockEventMapping: EventMapping {
- static var events: [PhishingDetectionEvents] = []
+public class MockEventMapping: EventMapping {
+ static var events: [MaliciousSiteProtection.Event] = []
static var clientSideHitParam: String?
static var errorParam: Error?
@@ -39,7 +40,7 @@ public class MockEventMapping: EventMapping {
}
}
- override init(mapping: @escaping EventMapping.Mapping) {
+ override init(mapping: @escaping EventMapping.Mapping) {
fatalError("Use init()")
}
}
diff --git a/Tests/MaliciousSiteProtectionTests/Mocks/MockMaliciousSiteProtectionAPIClient.swift b/Tests/MaliciousSiteProtectionTests/Mocks/MockMaliciousSiteProtectionAPIClient.swift
new file mode 100644
index 000000000..4f2062edd
--- /dev/null
+++ b/Tests/MaliciousSiteProtectionTests/Mocks/MockMaliciousSiteProtectionAPIClient.swift
@@ -0,0 +1,103 @@
+//
+// MockMaliciousSiteProtectionAPIClient.swift
+//
+// Copyright © 2024 DuckDuckGo. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+@testable import MaliciousSiteProtection
+
+class MockMaliciousSiteProtectionAPIClient: MaliciousSiteProtection.APIClient.Mockable {
+ var updateHashPrefixesCalled: ((Int) -> Void)?
+ var updateFilterSetsCalled: ((Int) -> Void)?
+
+ var filterRevisions: [Int: APIClient.Response.FiltersChangeSet] = [
+ 0: .init(insert: [
+ Filter(hash: "testhash1", regex: ".*example.*"),
+ Filter(hash: "testhash2", regex: ".*test.*")
+ ], delete: [], revision: 1, replace: false),
+ 1: .init(insert: [
+ Filter(hash: "testhash3", regex: ".*test.*")
+ ], delete: [
+ Filter(hash: "testhash1", regex: ".*example.*"),
+ ], revision: 2, replace: false),
+ 2: .init(insert: [
+ Filter(hash: "testhash4", regex: ".*test.*")
+ ], delete: [
+ Filter(hash: "testhash2", regex: ".*test.*"),
+ ], revision: 3, replace: false),
+ 4: .init(insert: [
+ Filter(hash: "testhash5", regex: ".*test.*")
+ ], delete: [
+ Filter(hash: "testhash3", regex: ".*test.*"),
+ ], revision: 5, replace: false),
+ 5: .init(insert: [
+ Filter(hash: "testhash6", regex: ".*test6.*")
+ ], delete: [
+ Filter(hash: "testhash3", regex: ".*test.*"),
+ ], revision: 6, replace: true),
+ ]
+
+ private var hashPrefixRevisions: [Int: APIClient.Response.HashPrefixesChangeSet] = [
+ 0: .init(insert: [
+ "aa00bb11",
+ "bb00cc11",
+ "cc00dd11",
+ "dd00ee11",
+ "a379a6f6"
+ ], delete: [], revision: 1, replace: false),
+ 1: .init(insert: ["93e2435e"], delete: [
+ "cc00dd11",
+ "dd00ee11",
+ ], revision: 2, replace: false),
+ 2: .init(insert: ["c0be0d0a6"], delete: [
+ "bb00cc11",
+ ], revision: 3, replace: false),
+ 4: .init(insert: ["a379a6f6"], delete: [
+ "aa00bb11",
+ ], revision: 5, replace: false),
+ 5: .init(insert: ["aa55aa55"], delete: [
+ "ffgghhzz",
+ ], revision: 6, replace: true),
+ ]
+
+ func load(_ requestConfig: Request) async throws -> Request.Response where Request: APIClient.Request {
+ switch requestConfig.requestType {
+ case .hashPrefixSet(let configuration):
+ return _hashPrefixesChangeSet(for: configuration.threatKind, revision: configuration.revision ?? 0) as! Request.Response
+ case .filterSet(let configuration):
+ return _filtersChangeSet(for: configuration.threatKind, revision: configuration.revision ?? 0) as! Request.Response
+ case .matches(let configuration):
+ return _matches(forHashPrefix: configuration.hashPrefix) as! Request.Response
+ }
+ }
+ func _filtersChangeSet(for threatKind: MaliciousSiteProtection.ThreatKind, revision: Int) -> MaliciousSiteProtection.APIClient.Response.FiltersChangeSet {
+ updateFilterSetsCalled?(revision)
+ return filterRevisions[revision] ?? .init(insert: [], delete: [], revision: revision, replace: false)
+ }
+
+ func _hashPrefixesChangeSet(for threatKind: MaliciousSiteProtection.ThreatKind, revision: Int) -> MaliciousSiteProtection.APIClient.Response.HashPrefixesChangeSet {
+ updateHashPrefixesCalled?(revision)
+ return hashPrefixRevisions[revision] ?? .init(insert: [], delete: [], revision: revision, replace: false)
+ }
+
+ func _matches(forHashPrefix hashPrefix: String) -> APIClient.Response.Matches {
+ .init(matches: [
+ Match(hostname: "example.com", url: "https://example.com/mal", regex: ".*", hash: "a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947", category: nil),
+ Match(hostname: "test.com", url: "https://test.com/mal", regex: ".*test.*", hash: "aa00bb11aa00cc11bb00cc11", category: nil)
+ ])
+ }
+
+}
diff --git a/Tests/MaliciousSiteProtectionTests/Mocks/MockMaliciousSiteProtectionDataManager.swift b/Tests/MaliciousSiteProtectionTests/Mocks/MockMaliciousSiteProtectionDataManager.swift
new file mode 100644
index 000000000..1a67ad329
--- /dev/null
+++ b/Tests/MaliciousSiteProtectionTests/Mocks/MockMaliciousSiteProtectionDataManager.swift
@@ -0,0 +1,40 @@
+//
+// MockMaliciousSiteProtectionDataManager.swift
+//
+// Copyright © 2024 DuckDuckGo. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Combine
+import Foundation
+@testable import MaliciousSiteProtection
+
+actor MockMaliciousSiteProtectionDataManager: MaliciousSiteProtection.DataManaging {
+
+ @Published var store = [MaliciousSiteProtection.DataManager.StoredDataType: Any]()
+ func publisher(for key: DataKey) -> AnyPublisher where DataKey: MaliciousSiteProtection.MaliciousSiteDataKey {
+ $store.map { $0[key.dataType] as? DataKey.DataSet ?? .init(revision: 0, items: []) }
+ .removeDuplicates()
+ .eraseToAnyPublisher()
+ }
+
+ public func dataSet(for key: DataKey) -> DataKey.DataSet where DataKey: MaliciousSiteProtection.MaliciousSiteDataKey {
+ return store[key.dataType] as? DataKey.DataSet ?? .init(revision: 0, items: [])
+ }
+
+ func store(_ dataSet: DataKey.DataSet, for key: DataKey) async where DataKey: MaliciousSiteProtection.MaliciousSiteDataKey {
+ store[key.dataType] = dataSet
+ }
+
+}
diff --git a/Tests/MaliciousSiteProtectionTests/Mocks/MockMaliciousSiteProtectionEmbeddedDataProvider.swift b/Tests/MaliciousSiteProtectionTests/Mocks/MockMaliciousSiteProtectionEmbeddedDataProvider.swift
new file mode 100644
index 000000000..10a3e2643
--- /dev/null
+++ b/Tests/MaliciousSiteProtectionTests/Mocks/MockMaliciousSiteProtectionEmbeddedDataProvider.swift
@@ -0,0 +1,81 @@
+//
+// MockMaliciousSiteProtectionEmbeddedDataProvider.swift
+//
+// Copyright © 2024 DuckDuckGo. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+@testable import MaliciousSiteProtection
+
+final class MockMaliciousSiteProtectionEmbeddedDataProvider: MaliciousSiteProtection.EmbeddedDataProviding {
+ var embeddedRevision: Int = 65
+ var loadHashPrefixesCalled: Bool = false
+ var loadFilterSetCalled: Bool = true
+ var hashPrefixes: Set = [] {
+ didSet {
+ hashPrefixesData = try! JSONEncoder().encode(hashPrefixes)
+ }
+ }
+ var hashPrefixesData: Data!
+
+ var filterSet: Set = [] {
+ didSet {
+ filterSetData = try! JSONEncoder().encode(filterSet)
+ }
+ }
+ var filterSetData: Data!
+
+ init() {
+ hashPrefixes = Set(["aabb"])
+ filterSet = Set([Filter(hash: "dummyhash", regex: "dummyregex")])
+ }
+
+ func revision(for detectionKind: MaliciousSiteProtection.DataManager.StoredDataType) -> Int {
+ embeddedRevision
+ }
+
+ func url(for dataType: MaliciousSiteProtection.DataManager.StoredDataType) -> URL {
+ switch dataType {
+ case .filterSet:
+ self.loadFilterSetCalled = true
+ return URL(string: "filterSet")!
+ case .hashPrefixSet:
+ self.loadHashPrefixesCalled = true
+ return URL(string: "hashPrefixSet")!
+ }
+ }
+
+ func hash(for dataType: MaliciousSiteProtection.DataManager.StoredDataType) -> String {
+ let url = url(for: dataType)
+ let data = try! data(withContentsOf: url)
+ let sha = data.sha256
+ return sha
+ }
+
+ func data(withContentsOf url: URL) throws -> Data {
+ let data: Data
+ switch url.absoluteString {
+ case "filterSet":
+ self.loadFilterSetCalled = true
+ return filterSetData
+ case "hashPrefixSet":
+ self.loadHashPrefixesCalled = true
+ return hashPrefixesData
+ default:
+ fatalError("Unexpected url \(url.absoluteString)")
+ }
+ }
+
+}
diff --git a/Tests/PhishingDetectionTests/Mocks/PhishingDetectionUpdateManagerMock.swift b/Tests/MaliciousSiteProtectionTests/Mocks/MockPhishingDetectionUpdateManager.swift
similarity index 59%
rename from Tests/PhishingDetectionTests/Mocks/PhishingDetectionUpdateManagerMock.swift
rename to Tests/MaliciousSiteProtectionTests/Mocks/MockPhishingDetectionUpdateManager.swift
index d5ca12559..b49eac588 100644
--- a/Tests/PhishingDetectionTests/Mocks/PhishingDetectionUpdateManagerMock.swift
+++ b/Tests/MaliciousSiteProtectionTests/Mocks/MockPhishingDetectionUpdateManager.swift
@@ -1,5 +1,5 @@
//
-// PhishingDetectionUpdateManagerMock.swift
+// MockPhishingDetectionUpdateManager.swift
//
// Copyright © 2024 DuckDuckGo. All rights reserved.
//
@@ -17,27 +17,40 @@
//
import Foundation
-import PhishingDetection
+@testable import MaliciousSiteProtection
+
+class MockPhishingDetectionUpdateManager: MaliciousSiteProtection.UpdateManaging {
-public class MockPhishingDetectionUpdateManager: PhishingDetectionUpdateManaging {
var didUpdateFilterSet = false
var didUpdateHashPrefixes = false
+ var startPeriodicUpdatesCalled = false
var completionHandler: (() -> Void)?
- public func updateFilterSet() async {
+ func updateData(for key: some MaliciousSiteProtection.MaliciousSiteDataKey) async {
+ switch key.dataType {
+ case .filterSet: await updateFilterSet()
+ case .hashPrefixSet: await updateHashPrefixes()
+ }
+ }
+
+ func updateFilterSet() async {
didUpdateFilterSet = true
checkCompletion()
}
- public func updateHashPrefixes() async {
+ func updateHashPrefixes() async {
didUpdateHashPrefixes = true
checkCompletion()
}
- private func checkCompletion() {
+ func checkCompletion() {
if didUpdateFilterSet && didUpdateHashPrefixes {
completionHandler?()
}
}
+ public func startPeriodicUpdates() -> Task {
+ startPeriodicUpdatesCalled = true
+ return Task {}
+ }
}
diff --git a/Tests/PhishingDetectionTests/Resources/filterSet.json b/Tests/MaliciousSiteProtectionTests/Resources/phishingFilterSet.json
similarity index 100%
rename from Tests/PhishingDetectionTests/Resources/filterSet.json
rename to Tests/MaliciousSiteProtectionTests/Resources/phishingFilterSet.json
diff --git a/Tests/PhishingDetectionTests/Resources/hashPrefixes.json b/Tests/MaliciousSiteProtectionTests/Resources/phishingHashPrefixes.json
similarity index 100%
rename from Tests/PhishingDetectionTests/Resources/hashPrefixes.json
rename to Tests/MaliciousSiteProtectionTests/Resources/phishingHashPrefixes.json
diff --git a/Tests/NavigationTests/Helpers/NavigationResponderMock.swift b/Tests/NavigationTests/Helpers/NavigationResponderMock.swift
index d39a6ee44..fda1b2805 100644
--- a/Tests/NavigationTests/Helpers/NavigationResponderMock.swift
+++ b/Tests/NavigationTests/Helpers/NavigationResponderMock.swift
@@ -374,7 +374,6 @@ class NavigationResponderMock: NavigationResponder {
var onDidTerminate: (@MainActor (WKProcessTerminationReason?) -> Void)?
func webContentProcessDidTerminate(with reason: WKProcessTerminationReason?) {
- let event = append(.didTerminate(reason))
onDidTerminate?(reason)
}
diff --git a/Tests/NetworkingTests/v2/APIRequestV2Tests.swift b/Tests/NetworkingTests/v2/APIRequestV2Tests.swift
index 59eeadebb..4ec1b8b59 100644
--- a/Tests/NetworkingTests/v2/APIRequestV2Tests.swift
+++ b/Tests/NetworkingTests/v2/APIRequestV2Tests.swift
@@ -41,21 +41,18 @@ final class APIRequestV2Tests: XCTestCase {
cachePolicy: cachePolicy,
responseConstraints: constraints)
- guard let urlRequest = apiRequest?.urlRequest else {
- XCTFail("Nil URLRequest")
- return
- }
+ let urlRequest = apiRequest.urlRequest
XCTAssertEqual(urlRequest.url?.host(), url.host())
XCTAssertEqual(urlRequest.httpMethod, method.rawValue)
let urlComponents = URLComponents(string: urlRequest.url!.absoluteString)!
- XCTAssertTrue(urlComponents.queryItems!.contains(queryItems.toURLQueryItems()))
+ XCTAssertTrue(urlComponents.queryItems!.contains(URLQueryItem(name: "key", value: "value")))
XCTAssertEqual(urlRequest.allHTTPHeaderFields, headers.httpHeaders)
XCTAssertEqual(urlRequest.httpBody, body)
- XCTAssertEqual(apiRequest?.timeoutInterval, timeoutInterval)
+ XCTAssertEqual(apiRequest.timeoutInterval, timeoutInterval)
XCTAssertEqual(urlRequest.cachePolicy, cachePolicy)
- XCTAssertEqual(apiRequest?.responseConstraints, constraints)
+ XCTAssertEqual(apiRequest.responseConstraints, constraints)
}
func testURLRequestGeneration() {
@@ -75,16 +72,16 @@ final class APIRequestV2Tests: XCTestCase {
timeoutInterval: timeoutInterval,
cachePolicy: cachePolicy)
- let urlComponents = URLComponents(string: apiRequest!.urlRequest.url!.absoluteString)!
- XCTAssertTrue(urlComponents.queryItems!.contains(queryItems.toURLQueryItems()))
+ let urlComponents = URLComponents(string: apiRequest.urlRequest.url!.absoluteString)!
+ XCTAssertTrue(urlComponents.queryItems!.contains(URLQueryItem(name: "key", value: "value")))
XCTAssertNotNil(apiRequest)
- XCTAssertEqual(apiRequest?.urlRequest.url?.absoluteString, "https://www.example.com?key=value")
- XCTAssertEqual(apiRequest?.urlRequest.httpMethod, method.rawValue)
- XCTAssertEqual(apiRequest?.urlRequest.allHTTPHeaderFields, headers.httpHeaders)
- XCTAssertEqual(apiRequest?.urlRequest.httpBody, body)
- XCTAssertEqual(apiRequest?.urlRequest.timeoutInterval, timeoutInterval)
- XCTAssertEqual(apiRequest?.urlRequest.cachePolicy, cachePolicy)
+ XCTAssertEqual(apiRequest.urlRequest.url?.absoluteString, "https://www.example.com?key=value")
+ XCTAssertEqual(apiRequest.urlRequest.httpMethod, method.rawValue)
+ XCTAssertEqual(apiRequest.urlRequest.allHTTPHeaderFields, headers.httpHeaders)
+ XCTAssertEqual(apiRequest.urlRequest.httpBody, body)
+ XCTAssertEqual(apiRequest.urlRequest.timeoutInterval, timeoutInterval)
+ XCTAssertEqual(apiRequest.urlRequest.cachePolicy, cachePolicy)
}
func testDefaultValues() {
@@ -92,16 +89,13 @@ final class APIRequestV2Tests: XCTestCase {
let apiRequest = APIRequestV2(url: url)
let headers = APIRequestV2.HeadersV2()
- guard let urlRequest = apiRequest?.urlRequest else {
- XCTFail("Nil URLRequest")
- return
- }
+ let urlRequest = apiRequest.urlRequest
XCTAssertEqual(urlRequest.httpMethod, HTTPRequestMethod.get.rawValue)
XCTAssertEqual(urlRequest.timeoutInterval, 60.0)
XCTAssertEqual(headers.httpHeaders, urlRequest.allHTTPHeaderFields)
XCTAssertNil(urlRequest.httpBody)
XCTAssertEqual(urlRequest.cachePolicy.rawValue, 0)
- XCTAssertNil(apiRequest?.responseConstraints)
+ XCTAssertNil(apiRequest.responseConstraints)
}
func testAllowedQueryReservedCharacters() {
@@ -112,9 +106,10 @@ final class APIRequestV2Tests: XCTestCase {
queryItems: queryItems,
allowedQueryReservedCharacters: CharacterSet(charactersIn: ","))
- let urlString = apiRequest!.urlRequest.url!.absoluteString
- XCTAssertTrue(urlString == "https://www.example.com?k%2523e,y=val%2523ue")
+ let urlString = apiRequest.urlRequest.url!.absoluteString
+ XCTAssertEqual(urlString, "https://www.example.com?k%23e,y=val%23ue")
+
let urlComponents = URLComponents(string: urlString)!
- XCTAssertTrue(urlComponents.queryItems?.count == 1)
+ XCTAssertEqual(urlComponents.queryItems?.count, 1)
}
}
diff --git a/Tests/NetworkingTests/v2/APIServiceTests.swift b/Tests/NetworkingTests/v2/APIServiceTests.swift
index 9cae44323..730d6afbb 100644
--- a/Tests/NetworkingTests/v2/APIServiceTests.swift
+++ b/Tests/NetworkingTests/v2/APIServiceTests.swift
@@ -41,7 +41,7 @@ final class APIServiceTests: XCTestCase {
cachePolicy: .reloadIgnoringLocalAndRemoteCacheData,
responseConstraints: [APIResponseConstraints.allowHTTPNotModified,
APIResponseConstraints.requireETagHeader],
- allowedQueryReservedCharacters: CharacterSet(charactersIn: ","))!
+ allowedQueryReservedCharacters: CharacterSet(charactersIn: ","))
let apiService = DefaultAPIService()
let response = try await apiService.fetch(request: request)
let responseHTML: String = try response.decodeBody()
@@ -50,7 +50,7 @@ final class APIServiceTests: XCTestCase {
func disabled_testRealCallJSON() async throws {
// func testRealCallJSON() async throws {
- let request = APIRequestV2(url: HTTPURLResponse.testUrl)!
+ let request = APIRequestV2(url: HTTPURLResponse.testUrl)
let apiService = DefaultAPIService()
let result = try await apiService.fetch(request: request)
@@ -63,7 +63,7 @@ final class APIServiceTests: XCTestCase {
func disabled_testRealCallString() async throws {
// func testRealCallString() async throws {
- let request = APIRequestV2(url: HTTPURLResponse.testUrl)!
+ let request = APIRequestV2(url: HTTPURLResponse.testUrl)
let apiService = DefaultAPIService()
let result = try await apiService.fetch(request: request)
@@ -75,17 +75,16 @@ final class APIServiceTests: XCTestCase {
"qName2": "qValue2"]
MockURLProtocol.requestHandler = { request in
let urlComponents = URLComponents(string: request.url!.absoluteString)!
- XCTAssertTrue(urlComponents.queryItems!.contains(qItems.toURLQueryItems()))
+ XCTAssertTrue(urlComponents.queryItems!.contains(qItems.map { URLQueryItem(name: $0.key, value: $0.value) }))
return (HTTPURLResponse.ok, nil)
}
- let request = APIRequestV2(url: HTTPURLResponse.testUrl,
- queryItems: qItems)!
+ let request = APIRequestV2(url: HTTPURLResponse.testUrl, queryItems: qItems)
let apiService = DefaultAPIService(urlSession: mockURLSession)
_ = try await apiService.fetch(request: request)
}
func testURLRequestError() async throws {
- let request = APIRequestV2(url: HTTPURLResponse.testUrl)!
+ let request = APIRequestV2(url: HTTPURLResponse.testUrl)
enum TestError: Error {
case anError
@@ -111,7 +110,7 @@ final class APIServiceTests: XCTestCase {
func testResponseRequirementAllowHTTPNotModifiedSuccess() async throws {
let requirements = [APIResponseConstraints.allowHTTPNotModified ]
- let request = APIRequestV2(url: HTTPURLResponse.testUrl, responseConstraints: requirements)!
+ let request = APIRequestV2(url: HTTPURLResponse.testUrl, responseConstraints: requirements)
MockURLProtocol.requestHandler = { _ in ( HTTPURLResponse.notModified, Data()) }
@@ -122,7 +121,7 @@ final class APIServiceTests: XCTestCase {
}
func testResponseRequirementAllowHTTPNotModifiedFailure() async throws {
- let request = APIRequestV2(url: HTTPURLResponse.testUrl)!
+ let request = APIRequestV2(url: HTTPURLResponse.testUrl)
MockURLProtocol.requestHandler = { _ in ( HTTPURLResponse.notModified, Data()) }
@@ -147,7 +146,7 @@ final class APIServiceTests: XCTestCase {
let requirements: [APIResponseConstraints] = [
APIResponseConstraints.requireETagHeader
]
- let request = APIRequestV2(url: HTTPURLResponse.testUrl, responseConstraints: requirements)!
+ let request = APIRequestV2(url: HTTPURLResponse.testUrl, responseConstraints: requirements)
MockURLProtocol.requestHandler = { _ in ( HTTPURLResponse.ok, nil) } // HTTPURLResponse.ok contains etag
let apiService = DefaultAPIService(urlSession: mockURLSession)
@@ -158,7 +157,7 @@ final class APIServiceTests: XCTestCase {
func testResponseRequirementRequireETagHeaderFailure() async throws {
let requirements = [ APIResponseConstraints.requireETagHeader ]
- let request = APIRequestV2(url: HTTPURLResponse.testUrl, responseConstraints: requirements)!
+ let request = APIRequestV2(url: HTTPURLResponse.testUrl, responseConstraints: requirements)
MockURLProtocol.requestHandler = { _ in ( HTTPURLResponse.okNoEtag, nil) }
@@ -181,7 +180,7 @@ final class APIServiceTests: XCTestCase {
func testResponseRequirementRequireUserAgentSuccess() async throws {
let requirements = [ APIResponseConstraints.requireUserAgent ]
- let request = APIRequestV2(url: HTTPURLResponse.testUrl, responseConstraints: requirements)!
+ let request = APIRequestV2(url: HTTPURLResponse.testUrl, responseConstraints: requirements)
MockURLProtocol.requestHandler = { _ in
( HTTPURLResponse.okUserAgent, nil)
@@ -194,7 +193,7 @@ final class APIServiceTests: XCTestCase {
func testResponseRequirementRequireUserAgentFailure() async throws {
let requirements = [ APIResponseConstraints.requireUserAgent ]
- let request = APIRequestV2(url: HTTPURLResponse.testUrl, responseConstraints: requirements)!
+ let request = APIRequestV2(url: HTTPURLResponse.testUrl, responseConstraints: requirements)
MockURLProtocol.requestHandler = { _ in ( HTTPURLResponse.ok, nil) }
diff --git a/Tests/OnboardingTests/OnboardingSuggestionsViewModelsTests.swift b/Tests/OnboardingTests/OnboardingSuggestionsViewModelsTests.swift
index ed7927f4f..942543505 100644
--- a/Tests/OnboardingTests/OnboardingSuggestionsViewModelsTests.swift
+++ b/Tests/OnboardingTests/OnboardingSuggestionsViewModelsTests.swift
@@ -147,11 +147,11 @@ class CapturingOnboardingNavigationDelegate: OnboardingNavigationDelegate {
var suggestedSearchQuery: String?
var urlToNavigateTo: URL?
- func searchFor(_ query: String) {
+ func searchFromOnboarding(for query: String) {
suggestedSearchQuery = query
}
- func navigateTo(url: URL) {
+ func navigateFromOnboarding(to url: URL) {
urlToNavigateTo = url
}
}
diff --git a/Tests/PhishingDetectionTests/BackgroundActivitySchedulerTests.swift b/Tests/PhishingDetectionTests/BackgroundActivitySchedulerTests.swift
deleted file mode 100644
index 8d907efbd..000000000
--- a/Tests/PhishingDetectionTests/BackgroundActivitySchedulerTests.swift
+++ /dev/null
@@ -1,57 +0,0 @@
-//
-// BackgroundActivitySchedulerTests.swift
-//
-// Copyright © 2024 DuckDuckGo. All rights reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-import Foundation
-import XCTest
-@testable import PhishingDetection
-
-class BackgroundActivitySchedulerTests: XCTestCase {
- var scheduler: BackgroundActivityScheduler!
- var activityWasRun = false
-
- override func tearDown() {
- scheduler = nil
- super.tearDown()
- }
-
- func testStart() async throws {
- let expectation = self.expectation(description: "Activity should run")
- scheduler = BackgroundActivityScheduler(interval: 1, identifier: "test") {
- if !self.activityWasRun {
- self.activityWasRun = true
- expectation.fulfill()
- }
- }
- await scheduler.start()
- await fulfillment(of: [expectation], timeout: 2)
- XCTAssertTrue(activityWasRun)
- }
-
- func testRepeats() async throws {
- let expectation = self.expectation(description: "Activity should repeat")
- var runCount = 0
- scheduler = BackgroundActivityScheduler(interval: 1, identifier: "test") {
- runCount += 1
- if runCount == 2 {
- expectation.fulfill()
- }
- }
- await scheduler.start()
- await fulfillment(of: [expectation], timeout: 3)
- XCTAssertEqual(runCount, 2)
- }
-}
diff --git a/Tests/PhishingDetectionTests/Mocks/PhishingDetectionClientMock.swift b/Tests/PhishingDetectionTests/Mocks/PhishingDetectionClientMock.swift
deleted file mode 100644
index 9f39598b2..000000000
--- a/Tests/PhishingDetectionTests/Mocks/PhishingDetectionClientMock.swift
+++ /dev/null
@@ -1,84 +0,0 @@
-//
-// PhishingDetectionClientMock.swift
-//
-// Copyright © 2024 DuckDuckGo. All rights reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-
-import Foundation
-import PhishingDetection
-
-public class MockPhishingDetectionClient: PhishingDetectionClientProtocol {
- public var updateHashPrefixesWasCalled: Bool = false
- public var updateFilterSetsWasCalled: Bool = false
-
- private var filterRevisions: [Int: FilterSetResponse] = [
- 0: FilterSetResponse(insert: [
- Filter(hashValue: "testhash1", regex: ".*example.*"),
- Filter(hashValue: "testhash2", regex: ".*test.*")
- ], delete: [], revision: 0, replace: true),
- 1: FilterSetResponse(insert: [
- Filter(hashValue: "testhash3", regex: ".*test.*")
- ], delete: [
- Filter(hashValue: "testhash1", regex: ".*example.*"),
- ], revision: 1, replace: false),
- 2: FilterSetResponse(insert: [
- Filter(hashValue: "testhash4", regex: ".*test.*")
- ], delete: [
- Filter(hashValue: "testhash2", regex: ".*test.*"),
- ], revision: 2, replace: false),
- 4: FilterSetResponse(insert: [
- Filter(hashValue: "testhash5", regex: ".*test.*")
- ], delete: [
- Filter(hashValue: "testhash3", regex: ".*test.*"),
- ], revision: 4, replace: false)
- ]
-
- private var hashPrefixRevisions: [Int: HashPrefixResponse] = [
- 0: HashPrefixResponse(insert: [
- "aa00bb11",
- "bb00cc11",
- "cc00dd11",
- "dd00ee11",
- "a379a6f6"
- ], delete: [], revision: 0, replace: true),
- 1: HashPrefixResponse(insert: ["93e2435e"], delete: [
- "cc00dd11",
- "dd00ee11",
- ], revision: 1, replace: false),
- 2: HashPrefixResponse(insert: ["c0be0d0a6"], delete: [
- "bb00cc11",
- ], revision: 2, replace: false),
- 4: HashPrefixResponse(insert: ["a379a6f6"], delete: [
- "aa00bb11",
- ], revision: 4, replace: false)
- ]
-
- public func getFilterSet(revision: Int) async -> FilterSetResponse {
- updateFilterSetsWasCalled = true
- return filterRevisions[revision] ?? FilterSetResponse(insert: [], delete: [], revision: revision, replace: false)
- }
-
- public func getHashPrefixes(revision: Int) async -> HashPrefixResponse {
- updateHashPrefixesWasCalled = true
- return hashPrefixRevisions[revision] ?? HashPrefixResponse(insert: [], delete: [], revision: revision, replace: false)
- }
-
- public func getMatches(hashPrefix: String) async -> [Match] {
- return [
- Match(hostname: "example.com", url: "https://example.com/mal", regex: ".", hash: "a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947"),
- Match(hostname: "test.com", url: "https://test.com/mal", regex: ".*test.*", hash: "aa00bb11aa00cc11bb00cc11")
- ]
- }
-}
diff --git a/Tests/PhishingDetectionTests/Mocks/PhishingDetectionDataProviderMock.swift b/Tests/PhishingDetectionTests/Mocks/PhishingDetectionDataProviderMock.swift
deleted file mode 100644
index 79d4d5d6b..000000000
--- a/Tests/PhishingDetectionTests/Mocks/PhishingDetectionDataProviderMock.swift
+++ /dev/null
@@ -1,47 +0,0 @@
-//
-// PhishingDetectionDataProviderMock.swift
-//
-// Copyright © 2024 DuckDuckGo. All rights reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-
-import Foundation
-import PhishingDetection
-
-public class MockPhishingDetectionDataProvider: PhishingDetectionDataProviding {
- public var embeddedRevision: Int = 65
- var loadHashPrefixesCalled: Bool = false
- var loadFilterSetCalled: Bool = true
- var hashPrefixes: Set = ["aabb"]
- var filterSet: Set = [Filter(hashValue: "dummyhash", regex: "dummyregex")]
-
- public func shouldReturnFilterSet(set: Set) {
- self.filterSet = set
- }
-
- public func shouldReturnHashPrefixes(set: Set) {
- self.hashPrefixes = set
- }
-
- public func loadEmbeddedFilterSet() -> Set {
- self.loadHashPrefixesCalled = true
- return self.filterSet
- }
-
- public func loadEmbeddedHashPrefixes() -> Set {
- self.loadFilterSetCalled = true
- return self.hashPrefixes
- }
-
-}
diff --git a/Tests/PhishingDetectionTests/Mocks/PhishingDetectionDataStoreMock.swift b/Tests/PhishingDetectionTests/Mocks/PhishingDetectionDataStoreMock.swift
deleted file mode 100644
index 54521419c..000000000
--- a/Tests/PhishingDetectionTests/Mocks/PhishingDetectionDataStoreMock.swift
+++ /dev/null
@@ -1,44 +0,0 @@
-//
-// PhishingDetectionDataStoreMock.swift
-//
-// Copyright © 2024 DuckDuckGo. All rights reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-
-import Foundation
-import PhishingDetection
-
-public class MockPhishingDetectionDataStore: PhishingDetectionDataSaving {
- public var filterSet: Set
- public var hashPrefixes: Set
- public var currentRevision: Int
-
- public init() {
- filterSet = Set()
- hashPrefixes = Set()
- currentRevision = 0
- }
-
- public func saveFilterSet(set: Set) {
- filterSet = set
- }
-
- public func saveHashPrefixes(set: Set) {
- hashPrefixes = set
- }
-
- public func saveRevision(_ revision: Int) {
- currentRevision = revision
- }
-}
diff --git a/Tests/PhishingDetectionTests/PhishingDetectionClientTests.swift b/Tests/PhishingDetectionTests/PhishingDetectionClientTests.swift
deleted file mode 100644
index 6826c86d6..000000000
--- a/Tests/PhishingDetectionTests/PhishingDetectionClientTests.swift
+++ /dev/null
@@ -1,125 +0,0 @@
-//
-// PhishingDetectionClientTests.swift
-//
-// Copyright © 2024 DuckDuckGo. All rights reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-import Foundation
-import XCTest
-@testable import PhishingDetection
-
-final class PhishingDetectionAPIClientTests: XCTestCase {
-
- var mockSession: MockURLSession!
- var client: PhishingDetectionAPIClient!
-
- override func setUp() {
- super.setUp()
- mockSession = MockURLSession()
- client = PhishingDetectionAPIClient(environment: .staging, session: mockSession)
- }
-
- override func tearDown() {
- mockSession = nil
- client = nil
- super.tearDown()
- }
-
- func testGetFilterSetSuccess() async {
- // Given
- let insertFilter = Filter(hashValue: "a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947", regex: ".")
- let deleteFilter = Filter(hashValue: "6a929cd0b3ba4677eaedf1b2bdaf3ff89281cca94f688c83103bc9a676aea46d", regex: "(?i)^https?\\:\\/\\/[\\w\\-\\.]+(?:\\:(?:80|443))?")
- let expectedResponse = FilterSetResponse(insert: [insertFilter], delete: [deleteFilter], revision: 1, replace: false)
- mockSession.data = try? JSONEncoder().encode(expectedResponse)
- mockSession.response = HTTPURLResponse(url: client.filterSetURL, statusCode: 200, httpVersion: nil, headerFields: nil)
-
- // When
- let response = await client.getFilterSet(revision: 1)
-
- // Then
- XCTAssertEqual(response, expectedResponse)
- }
-
- func testGetHashPrefixesSuccess() async {
- // Given
- let expectedResponse = HashPrefixResponse(insert: ["abc"], delete: ["def"], revision: 1, replace: false)
- mockSession.data = try? JSONEncoder().encode(expectedResponse)
- mockSession.response = HTTPURLResponse(url: client.hashPrefixURL, statusCode: 200, httpVersion: nil, headerFields: nil)
-
- // When
- let response = await client.getHashPrefixes(revision: 1)
-
- // Then
- XCTAssertEqual(response, expectedResponse)
- }
-
- func testGetMatchesSuccess() async {
- // Given
- let expectedResponse = MatchResponse(matches: [Match(hostname: "example.com", url: "https://example.com/test", regex: ".", hash: "a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947")])
- mockSession.data = try? JSONEncoder().encode(expectedResponse)
- mockSession.response = HTTPURLResponse(url: client.matchesURL, statusCode: 200, httpVersion: nil, headerFields: nil)
-
- // When
- let response = await client.getMatches(hashPrefix: "abc")
-
- // Then
- XCTAssertEqual(response, expectedResponse.matches)
- }
-
- func testGetFilterSetInvalidURL() async {
- // Given
- let invalidRevision = -1
-
- // When
- let response = await client.getFilterSet(revision: invalidRevision)
-
- // Then
- XCTAssertEqual(response, FilterSetResponse(insert: [], delete: [], revision: invalidRevision, replace: false))
- }
-
- func testGetHashPrefixesInvalidURL() async {
- // Given
- let invalidRevision = -1
-
- // When
- let response = await client.getHashPrefixes(revision: invalidRevision)
-
- // Then
- XCTAssertEqual(response, HashPrefixResponse(insert: [], delete: [], revision: invalidRevision, replace: false))
- }
-
- func testGetMatchesInvalidURL() async {
- // Given
- let invalidHashPrefix = ""
-
- // When
- let response = await client.getMatches(hashPrefix: invalidHashPrefix)
-
- // Then
- XCTAssertTrue(response.isEmpty)
- }
-}
-
-class MockURLSession: URLSessionProtocol {
- var data: Data?
- var response: URLResponse?
- var error: Error?
-
- func data(for request: URLRequest) async throws -> (Data, URLResponse) {
- if let error = error {
- throw error
- }
- return (data ?? Data(), response ?? URLResponse())
- }
-}
diff --git a/Tests/PhishingDetectionTests/PhishingDetectionDataActivitiesTests.swift b/Tests/PhishingDetectionTests/PhishingDetectionDataActivitiesTests.swift
deleted file mode 100644
index 583f94789..000000000
--- a/Tests/PhishingDetectionTests/PhishingDetectionDataActivitiesTests.swift
+++ /dev/null
@@ -1,48 +0,0 @@
-//
-// PhishingDetectionDataActivitiesTests.swift
-//
-// Copyright © 2024 DuckDuckGo. All rights reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-
-import Foundation
-import XCTest
-@testable import PhishingDetection
-
-class PhishingDetectionDataActivitiesTests: XCTestCase {
- var mockUpdateManager: MockPhishingDetectionUpdateManager!
- var activities: PhishingDetectionDataActivities!
-
- override func setUp() {
- super.setUp()
- mockUpdateManager = MockPhishingDetectionUpdateManager()
- activities = PhishingDetectionDataActivities(hashPrefixInterval: 1, filterSetInterval: 1, phishingDetectionDataProvider: MockPhishingDetectionDataProvider(), updateManager: mockUpdateManager)
- }
-
- func testUpdateHashPrefixesAndFilterSetRuns() async {
- let expectation = XCTestExpectation(description: "updateHashPrefixes and updateFilterSet completes")
-
- mockUpdateManager.completionHandler = {
- expectation.fulfill()
- }
-
- activities.start()
-
- await fulfillment(of: [expectation], timeout: 10.0)
-
- XCTAssertTrue(mockUpdateManager.didUpdateHashPrefixes)
- XCTAssertTrue(mockUpdateManager.didUpdateFilterSet)
-
- }
-}
diff --git a/Tests/PhishingDetectionTests/PhishingDetectionDataProviderTest.swift b/Tests/PhishingDetectionTests/PhishingDetectionDataProviderTest.swift
deleted file mode 100644
index 547f2dce8..000000000
--- a/Tests/PhishingDetectionTests/PhishingDetectionDataProviderTest.swift
+++ /dev/null
@@ -1,52 +0,0 @@
-//
-// PhishingDetectionDataProviderTest.swift
-//
-// Copyright © 2024 DuckDuckGo. All rights reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-import Foundation
-import XCTest
-@testable import PhishingDetection
-
-class PhishingDetectionDataProviderTest: XCTestCase {
- var filterSetURL: URL!
- var hashPrefixURL: URL!
- var dataProvider: PhishingDetectionDataProvider!
-
- override func setUp() {
- super.setUp()
- filterSetURL = Bundle.module.url(forResource: "filterSet", withExtension: "json")!
- hashPrefixURL = Bundle.module.url(forResource: "hashPrefixes", withExtension: "json")!
- }
-
- override func tearDown() {
- filterSetURL = nil
- hashPrefixURL = nil
- dataProvider = nil
- super.tearDown()
- }
-
- func testDataProviderLoadsJSON() {
- dataProvider = PhishingDetectionDataProvider(revision: 0, filterSetURL: filterSetURL, filterSetDataSHA: "4fd2868a4f264501ec175ab866504a2a96c8d21a3b5195b405a4a83b51eae504", hashPrefixURL: hashPrefixURL, hashPrefixDataSHA: "21b047a9950fcaf86034a6b16181e18815cb8d276386d85c8977ca8c5f8aa05f")
- let expectedFilter = Filter(hashValue: "e4753ddad954dafd4ff4ef67f82b3c1a2db6ef4a51bda43513260170e558bd13", regex: "(?i)^https?\\:\\/\\/privacy-test-pages\\.site(?:\\:(?:80|443))?\\/security\\/badware\\/phishing\\.html$")
- XCTAssertTrue(dataProvider.loadEmbeddedFilterSet().contains(expectedFilter))
- XCTAssertTrue(dataProvider.loadEmbeddedHashPrefixes().contains("012db806"))
- }
-
- func testReturnsNoneWhenSHAMismatch() {
- dataProvider = PhishingDetectionDataProvider(revision: 0, filterSetURL: filterSetURL, filterSetDataSHA: "xx0", hashPrefixURL: hashPrefixURL, hashPrefixDataSHA: "00x")
- XCTAssertTrue(dataProvider.loadEmbeddedFilterSet().isEmpty)
- XCTAssertTrue(dataProvider.loadEmbeddedHashPrefixes().isEmpty)
- }
-}
diff --git a/Tests/PhishingDetectionTests/PhishingDetectionDataStoreTests.swift b/Tests/PhishingDetectionTests/PhishingDetectionDataStoreTests.swift
deleted file mode 100644
index 79e9fb500..000000000
--- a/Tests/PhishingDetectionTests/PhishingDetectionDataStoreTests.swift
+++ /dev/null
@@ -1,197 +0,0 @@
-//
-// PhishingDetectionDataStoreTests.swift
-//
-// Copyright © 2024 DuckDuckGo. All rights reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-
-import Foundation
-
-import XCTest
-@testable import PhishingDetection
-
-class PhishingDetectionDataStoreTests: XCTestCase {
- var mockDataProvider: MockPhishingDetectionDataProvider!
- let datasetFiles: [String] = ["hashPrefixes.json", "filterSet.json", "revision.txt"]
- var dataStore: PhishingDetectionDataStore!
- var fileStorageManager: FileStorageManager!
-
- override func setUp() {
- super.setUp()
- mockDataProvider = MockPhishingDetectionDataProvider()
- fileStorageManager = MockPhishingFileStorageManager()
- dataStore = PhishingDetectionDataStore(dataProvider: mockDataProvider, fileStorageManager: fileStorageManager)
- }
-
- override func tearDown() {
- mockDataProvider = nil
- dataStore = nil
- super.tearDown()
- }
-
- func clearDatasets() {
- for fileName in datasetFiles {
- let emptyData = Data()
- fileStorageManager.write(data: emptyData, to: fileName)
- }
- }
-
- func testWhenNoDataSavedThenProviderDataReturned() async {
- clearDatasets()
- let expectedFilerSet = Set([Filter(hashValue: "some", regex: "some")])
- let expectedHashPrefix = Set(["sassa"])
- mockDataProvider.shouldReturnFilterSet(set: expectedFilerSet)
- mockDataProvider.shouldReturnHashPrefixes(set: expectedHashPrefix)
-
- let actualFilterSet = dataStore.filterSet
- let actualHashPrefix = dataStore.hashPrefixes
-
- XCTAssertEqual(actualFilterSet, expectedFilerSet)
- XCTAssertEqual(actualHashPrefix, expectedHashPrefix)
- }
-
- func testWhenEmbeddedRevisionNewerThanOnDisk_ThenLoadEmbedded() async {
- let encoder = JSONEncoder()
- // On Disk Data Setup
- fileStorageManager.write(data: "1".utf8data, to: "revision.txt")
- let onDiskFilterSet = Set([Filter(hashValue: "other", regex: "other")])
- let filterSetData = try! encoder.encode(Array(onDiskFilterSet))
- let onDiskHashPrefix = Set(["faffa"])
- let hashPrefixData = try! encoder.encode(Array(onDiskHashPrefix))
- fileStorageManager.write(data: filterSetData, to: "filterSet.json")
- fileStorageManager.write(data: hashPrefixData, to: "hashPrefixes.json")
-
- // Embedded Data Setup
- mockDataProvider.embeddedRevision = 5
- let embeddedFilterSet = Set([Filter(hashValue: "some", regex: "some")])
- let embeddedHashPrefix = Set(["sassa"])
- mockDataProvider.shouldReturnFilterSet(set: embeddedFilterSet)
- mockDataProvider.shouldReturnHashPrefixes(set: embeddedHashPrefix)
-
- let actualRevision = dataStore.currentRevision
- let actualFilterSet = dataStore.filterSet
- let actualHashPrefix = dataStore.hashPrefixes
-
- XCTAssertEqual(actualFilterSet, embeddedFilterSet)
- XCTAssertEqual(actualHashPrefix, embeddedHashPrefix)
- XCTAssertEqual(actualRevision, 5)
- }
-
- func testWhenEmbeddedRevisionOlderThanOnDisk_ThenDontLoadEmbedded() async {
- let encoder = JSONEncoder()
- // On Disk Data Setup
- fileStorageManager.write(data: "6".utf8data, to: "revision.txt")
- let onDiskFilterSet = Set([Filter(hashValue: "other", regex: "other")])
- let filterSetData = try! encoder.encode(Array(onDiskFilterSet))
- let onDiskHashPrefix = Set(["faffa"])
- let hashPrefixData = try! encoder.encode(Array(onDiskHashPrefix))
- fileStorageManager.write(data: filterSetData, to: "filterSet.json")
- fileStorageManager.write(data: hashPrefixData, to: "hashPrefixes.json")
-
- // Embedded Data Setup
- mockDataProvider.embeddedRevision = 1
- let embeddedFilterSet = Set([Filter(hashValue: "some", regex: "some")])
- let embeddedHashPrefix = Set(["sassa"])
- mockDataProvider.shouldReturnFilterSet(set: embeddedFilterSet)
- mockDataProvider.shouldReturnHashPrefixes(set: embeddedHashPrefix)
-
- let actualRevision = dataStore.currentRevision
- let actualFilterSet = dataStore.filterSet
- let actualHashPrefix = dataStore.hashPrefixes
-
- XCTAssertEqual(actualFilterSet, onDiskFilterSet)
- XCTAssertEqual(actualHashPrefix, onDiskHashPrefix)
- XCTAssertEqual(actualRevision, 6)
- }
-
- func testWriteAndLoadData() async {
- // Get and write data
- let expectedHashPrefixes = Set(["aabb"])
- let expectedFilterSet = Set([Filter(hashValue: "dummyhash", regex: "dummyregex")])
- let expectedRevision = 65
-
- dataStore.saveHashPrefixes(set: expectedHashPrefixes)
- dataStore.saveFilterSet(set: expectedFilterSet)
- dataStore.saveRevision(expectedRevision)
-
- XCTAssertEqual(dataStore.filterSet, expectedFilterSet)
- XCTAssertEqual(dataStore.hashPrefixes, expectedHashPrefixes)
- XCTAssertEqual(dataStore.currentRevision, expectedRevision)
-
- // Test decode JSON data to expected types
- let storedHashPrefixesData = fileStorageManager.read(from: "hashPrefixes.json")
- let storedFilterSetData = fileStorageManager.read(from: "filterSet.json")
- let storedRevisionData = fileStorageManager.read(from: "revision.txt")
-
- let decoder = JSONDecoder()
- if let storedHashPrefixes = try? decoder.decode(Set.self, from: storedHashPrefixesData!),
- let storedFilterSet = try? decoder.decode(Set.self, from: storedFilterSetData!),
- let storedRevisionString = String(data: storedRevisionData!, encoding: .utf8),
- let storedRevision = Int(storedRevisionString.trimmingCharacters(in: .whitespacesAndNewlines)) {
-
- XCTAssertEqual(storedFilterSet, expectedFilterSet)
- XCTAssertEqual(storedHashPrefixes, expectedHashPrefixes)
- XCTAssertEqual(storedRevision, expectedRevision)
- } else {
- XCTFail("Failed to decode stored PhishingDetection data")
- }
- }
-
- func testLazyLoadingDoesNotReturnStaleData() async {
- clearDatasets()
-
- // Set up initial data
- let initialFilterSet = Set([Filter(hashValue: "initial", regex: "initial")])
- let initialHashPrefixes = Set(["initialPrefix"])
- mockDataProvider.shouldReturnFilterSet(set: initialFilterSet)
- mockDataProvider.shouldReturnHashPrefixes(set: initialHashPrefixes)
-
- // Access the lazy-loaded properties to trigger loading
- let loadedFilterSet = dataStore.filterSet
- let loadedHashPrefixes = dataStore.hashPrefixes
-
- // Validate loaded data matches initial data
- XCTAssertEqual(loadedFilterSet, initialFilterSet)
- XCTAssertEqual(loadedHashPrefixes, initialHashPrefixes)
-
- // Update in-memory data
- let updatedFilterSet = Set([Filter(hashValue: "updated", regex: "updated")])
- let updatedHashPrefixes = Set(["updatedPrefix"])
- dataStore.saveFilterSet(set: updatedFilterSet)
- dataStore.saveHashPrefixes(set: updatedHashPrefixes)
-
- // Access lazy-loaded properties again
- let reloadedFilterSet = dataStore.filterSet
- let reloadedHashPrefixes = dataStore.hashPrefixes
-
- // Validate reloaded data matches updated data
- XCTAssertEqual(reloadedFilterSet, updatedFilterSet)
- XCTAssertEqual(reloadedHashPrefixes, updatedHashPrefixes)
-
- // Validate on-disk data is also updated
- let storedFilterSetData = fileStorageManager.read(from: "filterSet.json")
- let storedHashPrefixesData = fileStorageManager.read(from: "hashPrefixes.json")
-
- let decoder = JSONDecoder()
- if let storedFilterSet = try? decoder.decode(Set.self, from: storedFilterSetData!),
- let storedHashPrefixes = try? decoder.decode(Set.self, from: storedHashPrefixesData!) {
-
- XCTAssertEqual(storedFilterSet, updatedFilterSet)
- XCTAssertEqual(storedHashPrefixes, updatedHashPrefixes)
- } else {
- XCTFail("Failed to decode stored PhishingDetection data after update")
- }
- }
-
-}
diff --git a/Tests/PhishingDetectionTests/PhishingDetectionUpdateManagerTests.swift b/Tests/PhishingDetectionTests/PhishingDetectionUpdateManagerTests.swift
deleted file mode 100644
index 6fec6c134..000000000
--- a/Tests/PhishingDetectionTests/PhishingDetectionUpdateManagerTests.swift
+++ /dev/null
@@ -1,155 +0,0 @@
-//
-// PhishingDetectionUpdateManagerTests.swift
-//
-// Copyright © 2024 DuckDuckGo. All rights reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-
-import Foundation
-
-import XCTest
-@testable import PhishingDetection
-
-class PhishingDetectionUpdateManagerTests: XCTestCase {
- var updateManager: PhishingDetectionUpdateManager!
- var dataStore: PhishingDetectionDataSaving!
- var mockClient: MockPhishingDetectionClient!
-
- override func setUp() async throws {
- try await super.setUp()
- mockClient = MockPhishingDetectionClient()
- dataStore = MockPhishingDetectionDataStore()
- updateManager = PhishingDetectionUpdateManager(client: mockClient, dataStore: dataStore)
- dataStore.saveRevision(0)
- await updateManager.updateFilterSet()
- await updateManager.updateHashPrefixes()
- }
-
- override func tearDown() {
- updateManager = nil
- dataStore = nil
- mockClient = nil
- super.tearDown()
- }
-
- func testUpdateHashPrefixes() async {
- await updateManager.updateHashPrefixes()
- XCTAssertFalse(dataStore.hashPrefixes.isEmpty, "Hash prefixes should not be empty after update.")
- XCTAssertEqual(dataStore.hashPrefixes, [
- "aa00bb11",
- "bb00cc11",
- "cc00dd11",
- "dd00ee11",
- "a379a6f6"
- ])
- }
-
- func testUpdateFilterSet() async {
- await updateManager.updateFilterSet()
- XCTAssertEqual(dataStore.filterSet, [
- Filter(hashValue: "testhash1", regex: ".*example.*"),
- Filter(hashValue: "testhash2", regex: ".*test.*")
- ])
- }
-
- func testRevision1AddsAndDeletesData() async {
- let expectedFilterSet: Set = [
- Filter(hashValue: "testhash2", regex: ".*test.*"),
- Filter(hashValue: "testhash3", regex: ".*test.*")
- ]
- let expectedHashPrefixes: Set = [
- "aa00bb11",
- "bb00cc11",
- "a379a6f6",
- "93e2435e"
- ]
-
- // Save revision and update the filter set and hash prefixes
- dataStore.saveRevision(1)
- await updateManager.updateFilterSet()
- await updateManager.updateHashPrefixes()
-
- XCTAssertEqual(dataStore.filterSet, expectedFilterSet, "Filter set should match the expected set after update.")
- XCTAssertEqual(dataStore.hashPrefixes, expectedHashPrefixes, "Hash prefixes should match the expected set after update.")
- }
-
- func testRevision2AddsAndDeletesData() async {
- let expectedFilterSet: Set = [
- Filter(hashValue: "testhash4", regex: ".*test.*"),
- Filter(hashValue: "testhash1", regex: ".*example.*")
- ]
- let expectedHashPrefixes: Set = [
- "aa00bb11",
- "a379a6f6",
- "c0be0d0a6",
- "dd00ee11",
- "cc00dd11"
- ]
-
- // Save revision and update the filter set and hash prefixes
- dataStore.saveRevision(2)
- await updateManager.updateFilterSet()
- await updateManager.updateHashPrefixes()
-
- XCTAssertEqual(dataStore.filterSet, expectedFilterSet, "Filter set should match the expected set after update.")
- XCTAssertEqual(dataStore.hashPrefixes, expectedHashPrefixes, "Hash prefixes should match the expected set after update.")
- }
-
- func testRevision3AddsAndDeletesNothing() async {
- let expectedFilterSet = dataStore.filterSet
- let expectedHashPrefixes = dataStore.hashPrefixes
-
- // Save revision and update the filter set and hash prefixes
- dataStore.saveRevision(3)
- await updateManager.updateFilterSet()
- await updateManager.updateHashPrefixes()
-
- XCTAssertEqual(dataStore.filterSet, expectedFilterSet, "Filter set should match the expected set after update.")
- XCTAssertEqual(dataStore.hashPrefixes, expectedHashPrefixes, "Hash prefixes should match the expected set after update.")
- }
-
- func testRevision4AddsAndDeletesData() async {
- let expectedFilterSet: Set = [
- Filter(hashValue: "testhash2", regex: ".*test.*"),
- Filter(hashValue: "testhash1", regex: ".*example.*"),
- Filter(hashValue: "testhash5", regex: ".*test.*")
- ]
- let expectedHashPrefixes: Set = [
- "a379a6f6",
- "dd00ee11",
- "cc00dd11",
- "bb00cc11"
- ]
-
- // Save revision and update the filter set and hash prefixes
- dataStore.saveRevision(4)
- await updateManager.updateFilterSet()
- await updateManager.updateHashPrefixes()
-
- XCTAssertEqual(dataStore.filterSet, expectedFilterSet, "Filter set should match the expected set after update.")
- XCTAssertEqual(dataStore.hashPrefixes, expectedHashPrefixes, "Hash prefixes should match the expected set after update.")
- }
-}
-
-class MockPhishingFileStorageManager: FileStorageManager {
- private var data: [String: Data] = [:]
-
- func write(data: Data, to filename: String) {
- self.data[filename] = data
- }
-
- func read(from filename: String) -> Data? {
- return data[filename]
- }
-}
diff --git a/Tests/PhishingDetectionTests/PhishingDetectorTests.swift b/Tests/PhishingDetectionTests/PhishingDetectorTests.swift
deleted file mode 100644
index d2ef4a02e..000000000
--- a/Tests/PhishingDetectionTests/PhishingDetectorTests.swift
+++ /dev/null
@@ -1,104 +0,0 @@
-//
-// PhishingDetectorTests.swift
-//
-// Copyright © 2024 DuckDuckGo. All rights reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-//
-import Foundation
-import XCTest
-@testable import PhishingDetection
-
-class IsMaliciousTests: XCTestCase {
-
- private var mockAPIClient: MockPhishingDetectionClient!
- private var mockDataStore: MockPhishingDetectionDataStore!
- private var mockEventMapping: MockEventMapping!
- private var detector: PhishingDetector!
-
- override func setUp() {
- super.setUp()
- mockAPIClient = MockPhishingDetectionClient()
- mockDataStore = MockPhishingDetectionDataStore()
- mockEventMapping = MockEventMapping()
- detector = PhishingDetector(apiClient: mockAPIClient, dataStore: mockDataStore, eventMapping: mockEventMapping)
- }
-
- override func tearDown() {
- mockAPIClient = nil
- mockDataStore = nil
- mockEventMapping = nil
- detector = nil
- super.tearDown()
- }
-
- func testIsMaliciousWithLocalFilterHit() async {
- let filter = Filter(hashValue: "255a8a793097aeea1f06a19c08cde28db0eb34c660c6e4e7480c9525d034b16d", regex: ".*malicious.*")
- mockDataStore.filterSet = Set([filter])
- mockDataStore.hashPrefixes = Set(["255a8a79"])
-
- let url = URL(string: "https://malicious.com/")!
-
- let result = await detector.isMalicious(url: url)
-
- XCTAssertTrue(result)
- }
-
- func testIsMaliciousWithApiMatch() async {
- mockDataStore.filterSet = Set()
- mockDataStore.hashPrefixes = ["a379a6f6"]
-
- let url = URL(string: "https://example.com/mal")!
-
- let result = await detector.isMalicious(url: url)
-
- XCTAssertTrue(result)
- }
-
- func testIsMaliciousWithHashPrefixMatch() async {
- let filter = Filter(hashValue: "notamatch", regex: ".*malicious.*")
- mockDataStore.filterSet = [filter]
- mockDataStore.hashPrefixes = ["4c64eb24"] // matches safe.com
-
- let url = URL(string: "https://safe.com")!
-
- let result = await detector.isMalicious(url: url)
-
- XCTAssertFalse(result)
- }
-
- func testIsMaliciousWithFullHashMatch() async {
- // 4c64eb2468bcd3e113b37167e6b819aeccf550f974a6082ef17fb74ca68e823b
- let filter = Filter(hashValue: "4c64eb2468bcd3e113b37167e6b819aeccf550f974a6082ef17fb74ca68e823b", regex: "https://safe.com/maliciousURI")
- mockDataStore.filterSet = [filter]
- mockDataStore.hashPrefixes = ["4c64eb24"]
-
- let url = URL(string: "https://safe.com")!
-
- let result = await detector.isMalicious(url: url)
-
- XCTAssertFalse(result)
- }
-
- func testIsMaliciousWithNoHashPrefixMatch() async {
- let filter = Filter(hashValue: "testHash", regex: ".*malicious.*")
- mockDataStore.filterSet = [filter]
- mockDataStore.hashPrefixes = ["testPrefix"]
-
- let url = URL(string: "https://safe.com")!
-
- let result = await detector.isMalicious(url: url)
-
- XCTAssertFalse(result)
- }
-}
diff --git a/Tests/PrivacyDashboardTests/PrivacyDashboardControllerTests.swift b/Tests/PrivacyDashboardTests/PrivacyDashboardControllerTests.swift
index 4c03464fa..867e7b888 100644
--- a/Tests/PrivacyDashboardTests/PrivacyDashboardControllerTests.swift
+++ b/Tests/PrivacyDashboardTests/PrivacyDashboardControllerTests.swift
@@ -260,13 +260,13 @@ final class PrivacyDashboardControllerTests: XCTestCase {
func testWhenIsPhishingSetThenJavaScriptEvaluatedWithCorrectString() {
let expectation = XCTestExpectation()
- let privacyInfo = PrivacyInfo(url: URL(string: "someurl.com")!, parentEntity: nil, protectionStatus: .init(unprotectedTemporary: false, enabledFeatures: [], allowlisted: true, denylisted: true), isPhishing: false)
+ let privacyInfo = PrivacyInfo(url: URL(string: "someurl.com")!, parentEntity: nil, protectionStatus: .init(unprotectedTemporary: false, enabledFeatures: [], allowlisted: true, denylisted: true), malicousSiteThreatKind: .none)
makePrivacyDashboardController(entryPoint: .dashboard, privacyInfo: privacyInfo)
let config = WKWebViewConfiguration()
let mockWebView = MockWebView(frame: .zero, configuration: config, expectation: expectation)
privacyDashboardController.webView = mockWebView
- privacyDashboardController.privacyInfo!.isPhishing = true
+ privacyDashboardController.privacyInfo!.malicousSiteThreatKind = .phishing
wait(for: [expectation], timeout: 100)
XCTAssertEqual(mockWebView.capturedJavaScriptString, "window.onChangePhishingStatus({\"phishingStatus\":true})")
diff --git a/Tests/SpecialErrorPagesTests/SpecialErrorPagesTest.swift b/Tests/SpecialErrorPagesTests/SpecialErrorPagesTests.swift
similarity index 96%
rename from Tests/SpecialErrorPagesTests/SpecialErrorPagesTest.swift
rename to Tests/SpecialErrorPagesTests/SpecialErrorPagesTests.swift
index fa0ebf895..4eec5c739 100644
--- a/Tests/SpecialErrorPagesTests/SpecialErrorPagesTest.swift
+++ b/Tests/SpecialErrorPagesTests/SpecialErrorPagesTests.swift
@@ -1,5 +1,5 @@
//
-// SpecialErrorPagesTest.swift
+// SpecialErrorPagesTests.swift
//
// Copyright © 2023 DuckDuckGo. All rights reserved.
//
@@ -108,7 +108,7 @@ final class SpecialErrorPageUserScriptTests: XCTestCase {
@MainActor
func test_WhenHandlerForInitialSetUpCalled_AndIsEnabledTrue_ThenRightParameterReturned() async {
// GIVEN
- let expectedData = SpecialErrorData(kind: .ssl, errorType: "some error type", domain: "someDomain")
+ let expectedData = SpecialErrorData.ssl(type: .invalid, domain: "someDomain", eTldPlus1: nil)
var encodable: Encodable?
userScript.isEnabled = true
delegate.errorData = expectedData
@@ -191,11 +191,11 @@ class CapturingSpecialErrorPageUserScriptDelegate: SpecialErrorPageUserScriptDel
var visitSiteCalled = false
var advancedInfoPresentedCalled = false
- func leaveSite() {
+ func leaveSiteAction() {
leaveSiteCalled = true
}
- func visitSite() {
+ func visitSiteAction() {
visitSiteCalled = true
}