Skip to content

Commit

Permalink
Alessandro/malicious site protection update changes (#1171)
Browse files Browse the repository at this point in the history
**Required**:
Task/Issue URL:  https://app.asana.com/0/72649045549333/1209158005098852
iOS PR: duckduckgo/iOS#3837
macOS PR: duckduckgo/macos-browser#3750
What kind of version bump will this require?: Minor 

**Optional**:
Tech Design URL:
https://app.asana.com/0/1206329551987282/1209133564224796/f

**Description**:
This PR exposes a method to fetch Malicious Site Protection Datasets and store last update dates for datasets.
  • Loading branch information
alessandroboron committed Jan 31, 2025
1 parent 20b2408 commit e3520f7
Show file tree
Hide file tree
Showing 15 changed files with 709 additions and 160 deletions.
11 changes: 11 additions & 0 deletions Sources/MaliciousSiteProtection/Model/Event.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public extension PixelKit {
public static let clientSideHit = "clientSideHit"
public static let category = "category"
public static let settingToggledTo = "newState"
public static let datasetType = "type"
}
}

Expand All @@ -34,6 +35,7 @@ public enum Event: PixelKitEventV2 {
case settingToggled(to: Bool)
case matchesApiTimeout
case matchesApiFailure(Error)
case failedToDownloadInitialDataSets(category: ThreatKind, type: DataManager.StoredDataType.Kind)

public var name: String {
switch self {
Expand All @@ -49,6 +51,8 @@ public enum Event: PixelKitEventV2 {
return "malicious-site-protection_client-timeout"
case .matchesApiFailure:
return "malicious-site-protection_matches-api-error"
case .failedToDownloadInitialDataSets:
return "malicious-site-protection_failed-to-fetch-initial-datasets"
}
}

Expand All @@ -71,6 +75,11 @@ public enum Event: PixelKitEventV2 {
case .matchesApiTimeout,
.matchesApiFailure:
return [:]
case .failedToDownloadInitialDataSets(let category, let datasetType):
return [
PixelKit.Parameters.category: category.rawValue,
PixelKit.Parameters.datasetType: datasetType.rawValue,
]
}
}

Expand All @@ -88,6 +97,8 @@ public enum Event: PixelKitEventV2 {
return nil
case .matchesApiFailure(let error):
return error
case .failedToDownloadInitialDataSets:
return nil
}
}

Expand Down
12 changes: 11 additions & 1 deletion Sources/MaliciousSiteProtection/Model/StoredData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public extension DataManager {
case hashPrefixSet(HashPrefixes)
case filterSet(FilterSet)

enum Kind: CaseIterable {
public enum Kind: String, CaseIterable {
case hashPrefixSet, filterSet
}
// keep to get a compiler error when number of cases changes
Expand Down Expand Up @@ -66,6 +66,16 @@ public extension DataManager {
}
}.flatMap { $0 }
}

static func dataTypes(for kind: DataManager.StoredDataType.Kind) -> [DataManager.StoredDataType] {
ThreatKind.allCases.map { threatKind in
switch kind {
case .hashPrefixSet: .hashPrefixSet(.init(threatKind: threatKind))
case .filterSet: .filterSet(.init(threatKind: threatKind))
}
}
}

}
}

Expand Down
27 changes: 16 additions & 11 deletions Sources/MaliciousSiteProtection/Services/DataManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,20 @@ import os

protocol DataManaging {
func dataSet<DataKey: MaliciousSiteDataKey>(for key: DataKey) async -> DataKey.DataSet
func store<DataKey: MaliciousSiteDataKey>(_ dataSet: DataKey.DataSet, for key: DataKey) async
func store<DataKey: MaliciousSiteDataKey>(_ dataSet: DataKey.DataSet, for key: DataKey) async throws
}

public actor DataManager: DataManaging {

private let embeddedDataProvider: EmbeddedDataProviding
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) {
public init(fileStore: FileStoring, embeddedDataProvider: EmbeddedDataProviding?, fileNameProvider: @escaping FileNameProvider) {
self.embeddedDataProvider = embeddedDataProvider
self.fileStore = fileStore
self.fileNameProvider = fileNameProvider
Expand All @@ -48,12 +48,18 @@ public actor DataManager: DataManaging {
}

// read stored dataSet if it‘s newer than the embedded one
let dataSet = readStoredDataSet(for: key) ?? {
let dataSet: DataKey.DataSet

if let storedDataSet = readStoredDataSet(for: key) {
dataSet = storedDataSet
} else if let embeddedDataProvider {
// 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)
}()
dataSet = .init(revision: embeddedRevision, items: embeddedItems)
} else {
dataSet = DataKey.DataSet(revision: 0, items: [])
}

// cache
store[dataType] = dataSet
Expand All @@ -75,7 +81,7 @@ public actor DataManager: DataManaging {
}

// compare to the embedded data revision
let embeddedDataRevision = embeddedDataProvider.revision(for: dataType)
let embeddedDataRevision = embeddedDataProvider?.revision(for: dataType) ?? 0
guard storedDataSet.revision >= embeddedDataRevision else {
Logger.dataManager.error("Stored \(fileName) is outdated: revision: \(storedDataSet.revision), embedded revision: \(embeddedDataRevision).")
return nil
Expand All @@ -84,7 +90,7 @@ public actor DataManager: DataManaging {
return storedDataSet
}

func store<DataKey: MaliciousSiteDataKey>(_ dataSet: DataKey.DataSet, for key: DataKey) {
func store<DataKey: MaliciousSiteDataKey>(_ dataSet: DataKey.DataSet, for key: DataKey) throws {
let dataType = key.dataType
let fileName = fileNameProvider(dataType)
self.store[dataType] = dataSet
Expand All @@ -95,11 +101,10 @@ public actor DataManager: DataManaging {
} catch {
Logger.dataManager.error("Error encoding \(fileName): \(error.localizedDescription)")
assertionFailure("Failed to store data to \(fileName): \(error)")
return
throw error
}

let success = fileStore.write(data: data, to: fileName)
assert(success)
try fileStore.write(data: data, to: fileName)
}

}
7 changes: 3 additions & 4 deletions Sources/MaliciousSiteProtection/Services/FileStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import Foundation
import os

public protocol FileStoring {
@discardableResult func write(data: Data, to filename: String) -> Bool
func write(data: Data, to filename: String) throws
func read(from filename: String) -> Data?
}

Expand All @@ -40,14 +40,13 @@ public struct FileStore: FileStoring, CustomDebugStringConvertible {
}
}

public func write(data: Data, to filename: String) -> Bool {
public func write(data: Data, to filename: String) throws {
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
throw error
}
}

Expand Down
121 changes: 109 additions & 12 deletions Sources/MaliciousSiteProtection/Services/UpdateManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,34 +20,57 @@ import Common
import Foundation
import Networking
import os
import PixelKit

protocol UpdateManaging {
func updateData(for key: some MaliciousSiteDataKey) async

public protocol MaliciousSiteUpdateManaging {
#if os(iOS)
var lastHashPrefixSetUpdateDate: Date { get }
var lastFilterSetUpdateDate: Date { get }
func updateData(datasetType: DataManager.StoredDataType.Kind) -> Task<Void, Error>
#elseif os(macOS)
func startPeriodicUpdates() -> Task<Void, Error>
#endif
}

protocol InternalUpdateManaging: MaliciousSiteUpdateManaging {
func updateData(for key: some MaliciousSiteDataKey) async throws
}

public struct UpdateManager: UpdateManaging {
public struct UpdateManager: InternalUpdateManaging {

private let apiClient: APIClient.Mockable
private let dataManager: DataManaging

public typealias UpdateIntervalProvider = (DataManager.StoredDataType) -> TimeInterval?
private let updateIntervalProvider: UpdateIntervalProvider
private let sleeper: Sleeper
private let updateInfoStorage: MaliciousSiteProtectioUpdateManagerInfoStorage
private let pixelHandler: UpdateManagerPixelFiring.Type

#if os(iOS)
public var lastHashPrefixSetUpdateDate: Date {
updateInfoStorage.lastHashPrefixSetsUpdateDate
}

public var lastFilterSetUpdateDate: Date {
updateInfoStorage.lastFilterSetsUpdateDate
}
#endif

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) {
init(apiClient: APIClient.Mockable, dataManager: DataManaging, sleeper: Sleeper = .default, updateInfoStorage: MaliciousSiteProtectioUpdateManagerInfoStorage = UpdateManagerInfoStore(), pixelHandler: UpdateManagerPixelFiring.Type = PixelKit.self, updateIntervalProvider: @escaping UpdateIntervalProvider) {
self.apiClient = apiClient
self.dataManager = dataManager
self.updateIntervalProvider = updateIntervalProvider
self.sleeper = sleeper
self.updateInfoStorage = updateInfoStorage
self.pixelHandler = pixelHandler
}

func updateData<DataKey: MaliciousSiteDataKey>(for key: DataKey) async {
func updateData<DataKey: MaliciousSiteDataKey>(for key: DataKey) async throws {
// load currently stored data set
var dataSet = await dataManager.dataSet(for: key)
let oldRevision = dataSet.revision
Expand All @@ -58,22 +81,35 @@ public struct UpdateManager: UpdateManaging {
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
Logger.updateManager.error("error fetching \(type(of: key)).\(key.threatKind): \(error)")

// Fire a Pixel if it fails to load initial datasets
if case APIRequestV2.Error.urlSession(URLError.notConnectedToInternet) = error, dataSet.revision == 0 {
pixelHandler.fireFailedToDownloadInitialDatasets(threat: key.threatKind, datasetType: key.dataType.kind)
}

throw error
}

guard !changeSet.isEmpty || changeSet.revision != dataSet.revision else {
Logger.updateManager.debug("no changes to filter set")
Logger.updateManager.debug("no changes to \(type(of: key)).\(key.threatKind)")
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)")
do {
try await self.dataManager.store(dataSet, for: key)
Logger.updateManager.debug("\(type(of: key)).\(key.threatKind) updated from rev.\(oldRevision) to rev.\(dataSet.revision)")
} catch {
Logger.updateManager.error("\(type(of: key)).\(key.threatKind) failed to be saved")
throw error
}
}

#if os(macOS)
public func startPeriodicUpdates() -> Task<Void, any Error> {
Task.detached {
// run update jobs in background for every data type
Expand All @@ -89,13 +125,74 @@ public struct UpdateManager: UpdateManaging {
group.addTask {
// run periodically until the parent task is cancelled
try await performPeriodicJob(interval: updateInterval, sleeper: sleeper) {
await self.updateData(for: dataType.dataKey)
do {
try await self.updateData(for: dataType.dataKey)
} catch {
Logger.updateManager.warning("Failed periodic update for kind: \(dataType.dataKey.threatKind). Error: \(error)")
}
}
}
}
for try await _ in group {}
}
}
}
#endif

#if os(iOS)
public func updateData(datasetType: DataManager.StoredDataType.Kind) -> Task<Void, any Error> {
Task {
// run update jobs in background for every data type
await withTaskGroup(of: Bool.self) { group in
for dataType in DataManager.StoredDataType.dataTypes(for: datasetType) {
group.addTask {
do {
try await self.updateData(for: dataType.dataKey)
return true
} catch {
Logger.updateManager.error("Failed to update dataset type: \(datasetType.rawValue) for kind: \(dataType.dataKey.threatKind). Error: \(error)")
return false
}
}
}

// Check that at least one of the dataset type have updated
// swiftlint:disable:next reduce_boolean
let success = await group.reduce(false) { partial, newValue in
partial || newValue
}

if success {
await saveLastUpdateDate(for: datasetType)
}
}
}
}

@MainActor
private func saveLastUpdateDate(for kind: DataManager.StoredDataType.Kind) {
Logger.updateManager.debug("Saving last update date for kind: \(kind.rawValue)")

let date = Date()
switch kind {
case .hashPrefixSet:
updateInfoStorage.lastHashPrefixSetsUpdateDate = date
case .filterSet:
updateInfoStorage.lastFilterSetsUpdateDate = date
}
}
#endif

}

// MARK: - Update Manager + PixelKit

protocol UpdateManagerPixelFiring {
static func fireFailedToDownloadInitialDatasets(threat: ThreatKind, datasetType: DataManager.StoredDataType.Kind)
}

extension PixelKit: UpdateManagerPixelFiring {
static func fireFailedToDownloadInitialDatasets(threat: ThreatKind, datasetType: DataManager.StoredDataType.Kind) {
fire(DebugEvent(MaliciousSiteProtection.Event.failedToDownloadInitialDataSets(category: threat, type: datasetType)))
}
}
Loading

0 comments on commit e3520f7

Please sign in to comment.