From befc1f1094353d8d88a99ac08885684c978b2016 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Tue, 3 Dec 2024 16:56:44 +0500 Subject: [PATCH] Malware protection 1: rename PhishingDetection to MaliciousSiteProtection (#1091) Task/Issue URL: https://app.asana.com/0/1202406491309510/1208033567421351/f iOS PR: https://github.com/duckduckgo/iOS/pull/3642 macOS PR: https://github.com/duckduckgo/macos-browser/pull/3588 **Description**: - Renamed `PhishingDetection` to `MaliciousSiteProtection` - Adjusted and cleaned up some code parts --- .../BrowserServicesKit-Package.xcscheme | 12 +- Package.resolved | 27 ++ Package.swift | 29 +- .../Autofill/AutofillUserScript.swift | 4 +- .../UserScripts/SurrogatesUserScript.swift | 6 +- .../SpecialPagesUserScript.swift | 3 +- .../UserContentController.swift | 6 +- .../Features/PrivacyFeature.swift | 7 +- .../Common/Concurrency/TaskExtension.swift | 81 ++-- Sources/Common/Extensions/HashExtension.swift | 9 +- .../Common/Extensions/StringExtension.swift | 8 +- Sources/Common/Extensions/URLExtension.swift | 28 +- .../API/APIClient.swift | 129 ++++++ .../API/APIRequest.swift | 112 +++++ .../API/ChangeSetResponse.swift | 47 +++ .../API/MatchResponse.swift | 29 ++ .../Logger+MaliciousSiteProtection.swift} | 24 +- .../MaliciousSiteDetector.swift | 130 ++++++ .../Model/Event.swift} | 8 +- .../Model/Filter.swift | 21 +- .../Model/FilterDictionary.swift | 78 ++++ .../Model/HashPrefixSet.swift | 45 ++ .../Model/IncrementallyUpdatableDataSet.swift | 71 ++++ .../Model/LoadableFromEmbeddedData.swift | 34 ++ .../Model/MaliciousSiteError.swift | 94 +++++ .../MaliciousSiteProtection/Model/Match.swift | 29 +- .../Model/StoredData.swift | 104 +++++ .../Model/ThreatKind.swift | 27 ++ .../Services/DataManager.swift | 105 +++++ .../Services/EmbeddedDataProvider.swift | 56 +++ .../Services/FileStore.swift | 67 +++ .../Services/UpdateManager.swift | 101 +++++ .../Extensions/WKErrorExtension.swift | 8 + Sources/Networking/README.md | 12 +- Sources/Networking/v1/APIHeaders.swift | 2 +- Sources/Networking/v2/APIRequestV2.swift | 48 ++- Sources/Networking/v2/APIResponseV2.swift | 9 +- .../OnboardingSuggestionsViewModel.swift | 8 +- .../Logger+PhishingDetection.swift | 29 -- .../PhishingDetectionClient.swift | 177 -------- .../PhishingDetectionDataActivities.swift | 110 ----- .../PhishingDetectionDataProvider.swift | 75 ---- .../PhishingDetectionDataStore.swift | 266 ------------ .../PhishingDetectionUpdateManager.swift | 83 ---- .../PhishingDetection/PhishingDetector.swift | 130 ------ .../PrivacyDashboardController.swift | 21 +- .../PrivacyDashboardUserScript.swift | 11 +- Sources/PrivacyDashboard/PrivacyInfo.swift | 9 +- Sources/SpecialErrorPages/SSLErrorType.swift | 38 +- .../SpecialErrorPages/SpecialErrorData.swift | 57 ++- .../SpecialErrorPageUserScript.swift | 12 +- Sources/TestUtils/MockAPIService.swift | 12 +- Sources/UserScript/UserScript.swift | 2 +- ...rRulesManagerInitialCompilationTests.swift | 2 +- .../Extensions/StringExtensionTests.swift | 11 + .../MaliciousSiteDetectorTests.swift | 103 +++++ ...aliciousSiteProtectionAPIClientTests.swift | 143 +++++++ ...iciousSiteProtectionDataManagerTests.swift | 250 +++++++++++ ...teProtectionEmbeddedDataProviderTest.swift | 62 +++ .../MaliciousSiteProtectionURLTests.swift} | 7 +- ...iousSiteProtectionUpdateManagerTests.swift | 392 ++++++++++++++++++ .../Mocks/MockEventMapping.swift} | 13 +- ...MockMaliciousSiteProtectionAPIClient.swift | 103 +++++ ...ckMaliciousSiteProtectionDataManager.swift | 40 ++ ...usSiteProtectionEmbeddedDataProvider.swift | 81 ++++ .../MockPhishingDetectionUpdateManager.swift} | 25 +- .../Resources/phishingFilterSet.json} | 0 .../Resources/phishingHashPrefixes.json} | 0 .../Helpers/NavigationResponderMock.swift | 1 - .../v2/APIRequestV2Tests.swift | 41 +- .../NetworkingTests/v2/APIServiceTests.swift | 25 +- ...OnboardingSuggestionsViewModelsTests.swift | 4 +- .../BackgroundActivitySchedulerTests.swift | 57 --- .../Mocks/PhishingDetectionClientMock.swift | 84 ---- .../PhishingDetectionDataProviderMock.swift | 47 --- .../PhishingDetectionDataStoreMock.swift | 44 -- .../PhishingDetectionClientTests.swift | 125 ------ ...PhishingDetectionDataActivitiesTests.swift | 48 --- .../PhishingDetectionDataProviderTest.swift | 52 --- .../PhishingDetectionDataStoreTests.swift | 197 --------- .../PhishingDetectionUpdateManagerTests.swift | 155 ------- .../PhishingDetectorTests.swift | 104 ----- .../PrivacyDashboardControllerTests.swift | 4 +- ...est.swift => SpecialErrorPagesTests.swift} | 8 +- 84 files changed, 2826 insertions(+), 2052 deletions(-) create mode 100644 Sources/MaliciousSiteProtection/API/APIClient.swift create mode 100644 Sources/MaliciousSiteProtection/API/APIRequest.swift create mode 100644 Sources/MaliciousSiteProtection/API/ChangeSetResponse.swift create mode 100644 Sources/MaliciousSiteProtection/API/MatchResponse.swift rename Sources/{Networking/v2/Extensions/Dictionary+URLQueryItem.swift => MaliciousSiteProtection/Logger+MaliciousSiteProtection.swift} (53%) create mode 100644 Sources/MaliciousSiteProtection/MaliciousSiteDetector.swift rename Sources/{PhishingDetection/PhishingDetectionEvents.swift => MaliciousSiteProtection/Model/Event.swift} (92%) rename Tests/PhishingDetectionTests/Mocks/BackgroundActivitySchedulerMock.swift => Sources/MaliciousSiteProtection/Model/Filter.swift (65%) create mode 100644 Sources/MaliciousSiteProtection/Model/FilterDictionary.swift create mode 100644 Sources/MaliciousSiteProtection/Model/HashPrefixSet.swift create mode 100644 Sources/MaliciousSiteProtection/Model/IncrementallyUpdatableDataSet.swift create mode 100644 Sources/MaliciousSiteProtection/Model/LoadableFromEmbeddedData.swift create mode 100644 Sources/MaliciousSiteProtection/Model/MaliciousSiteError.swift rename Tests/PhishingDetectionTests/Mocks/PhishingDetectorMock.swift => Sources/MaliciousSiteProtection/Model/Match.swift (52%) create mode 100644 Sources/MaliciousSiteProtection/Model/StoredData.swift create mode 100644 Sources/MaliciousSiteProtection/Model/ThreatKind.swift create mode 100644 Sources/MaliciousSiteProtection/Services/DataManager.swift create mode 100644 Sources/MaliciousSiteProtection/Services/EmbeddedDataProvider.swift create mode 100644 Sources/MaliciousSiteProtection/Services/FileStore.swift create mode 100644 Sources/MaliciousSiteProtection/Services/UpdateManager.swift delete mode 100644 Sources/PhishingDetection/Logger+PhishingDetection.swift delete mode 100644 Sources/PhishingDetection/PhishingDetectionClient.swift delete mode 100644 Sources/PhishingDetection/PhishingDetectionDataActivities.swift delete mode 100644 Sources/PhishingDetection/PhishingDetectionDataProvider.swift delete mode 100644 Sources/PhishingDetection/PhishingDetectionDataStore.swift delete mode 100644 Sources/PhishingDetection/PhishingDetectionUpdateManager.swift delete mode 100644 Sources/PhishingDetection/PhishingDetector.swift create mode 100644 Tests/MaliciousSiteProtectionTests/MaliciousSiteDetectorTests.swift create mode 100644 Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionAPIClientTests.swift create mode 100644 Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionDataManagerTests.swift create mode 100644 Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionEmbeddedDataProviderTest.swift rename Tests/{PhishingDetectionTests/PhishingDetectionURLTests.swift => MaliciousSiteProtectionTests/MaliciousSiteProtectionURLTests.swift} (92%) create mode 100644 Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionUpdateManagerTests.swift rename Tests/{PhishingDetectionTests/Mocks/EventMappingMock.swift => MaliciousSiteProtectionTests/Mocks/MockEventMapping.swift} (80%) create mode 100644 Tests/MaliciousSiteProtectionTests/Mocks/MockMaliciousSiteProtectionAPIClient.swift create mode 100644 Tests/MaliciousSiteProtectionTests/Mocks/MockMaliciousSiteProtectionDataManager.swift create mode 100644 Tests/MaliciousSiteProtectionTests/Mocks/MockMaliciousSiteProtectionEmbeddedDataProvider.swift rename Tests/{PhishingDetectionTests/Mocks/PhishingDetectionUpdateManagerMock.swift => MaliciousSiteProtectionTests/Mocks/MockPhishingDetectionUpdateManager.swift} (59%) rename Tests/{PhishingDetectionTests/Resources/filterSet.json => MaliciousSiteProtectionTests/Resources/phishingFilterSet.json} (100%) rename Tests/{PhishingDetectionTests/Resources/hashPrefixes.json => MaliciousSiteProtectionTests/Resources/phishingHashPrefixes.json} (100%) delete mode 100644 Tests/PhishingDetectionTests/BackgroundActivitySchedulerTests.swift delete mode 100644 Tests/PhishingDetectionTests/Mocks/PhishingDetectionClientMock.swift delete mode 100644 Tests/PhishingDetectionTests/Mocks/PhishingDetectionDataProviderMock.swift delete mode 100644 Tests/PhishingDetectionTests/Mocks/PhishingDetectionDataStoreMock.swift delete mode 100644 Tests/PhishingDetectionTests/PhishingDetectionClientTests.swift delete mode 100644 Tests/PhishingDetectionTests/PhishingDetectionDataActivitiesTests.swift delete mode 100644 Tests/PhishingDetectionTests/PhishingDetectionDataProviderTest.swift delete mode 100644 Tests/PhishingDetectionTests/PhishingDetectionDataStoreTests.swift delete mode 100644 Tests/PhishingDetectionTests/PhishingDetectionUpdateManagerTests.swift delete mode 100644 Tests/PhishingDetectionTests/PhishingDetectorTests.swift rename Tests/SpecialErrorPagesTests/{SpecialErrorPagesTest.swift => SpecialErrorPagesTests.swift} (96%) 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 }