krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

v5.0.3: DomainDig/ExternalDataService.swift · raw

  1import Foundation
  2
  3actor ExternalDataService {
  4    static let shared = ExternalDataService()
  5
  6    private enum RequestKey: Hashable {
  7        case ownershipHistory(String)
  8        case dnsHistory(String)
  9        case extendedSubdomains(String)
 10        case pricing(String)
 11        case reputation(String)
 12    }
 13
 14    private enum RateLimitBucket: Hashable {
 15        case history
 16        case subdomains
 17        case pricing
 18        case reputation
 19
 20        var minimumSpacing: TimeInterval {
 21            switch self {
 22            case .history:
 23                return 1.0
 24            case .subdomains:
 25                return 1.5
 26            case .pricing:
 27                return 1.0
 28            case .reputation:
 29                return 1.0
 30            }
 31        }
 32    }
 33
 34    private enum CachedPayload {
 35        case ownershipHistory(ServiceResult<[DomainOwnershipHistoryEvent]>)
 36        case dnsHistory(ServiceResult<[DNSHistoryEvent]>)
 37        case extendedSubdomains(ServiceResult<[DiscoveredSubdomain]>)
 38        case pricing(ServiceResult<DomainPricingInsight>)
 39        case reputation(ServiceResult<DomainReputationResult>)
 40    }
 41
 42    private struct CacheEntry {
 43        let payload: CachedPayload
 44        let expiresAt: Date
 45    }
 46
 47    private struct Configuration {
 48        let ownershipHistoryURL: String?
 49        let dnsHistoryURL: String?
 50        let extendedSubdomainsURL: String?
 51        let pricingURL: String?
 52        let reputationURL: String?
 53    }
 54
 55    private let ttl: TimeInterval = 900
 56    private let session: URLSession = .shared
 57    private var cache: [RequestKey: CacheEntry] = [:]
 58    private var inFlight: [RequestKey: Task<CachedPayload, Never>] = [:]
 59    private var nextAllowedAt: [RateLimitBucket: Date] = [:]
 60
 61    func clearCache() {
 62        cache.removeAll()
 63        inFlight.values.forEach { $0.cancel() }
 64        inFlight.removeAll()
 65        nextAllowedAt.removeAll()
 66    }
 67
 68    func ownershipHistory(
 69        domain: String,
 70        currentOwnership: DomainOwnership?,
 71        historyEntries: [HistoryEntry]
 72    ) async -> CachedLookupResult<ServiceResult<[DomainOwnershipHistoryEvent]>> {
 73        let normalizedDomain = Self.normalize(domain)
 74        return await execute(
 75            key: .ownershipHistory(normalizedDomain),
 76            rateLimitBucket: .history,
 77            extract: { payload in
 78                guard case let .ownershipHistory(result) = payload else { return nil }
 79                return result
 80            },
 81            operation: { [configuration = configuration()] in
 82                let localEvents = Self.localOwnershipHistory(
 83                    domain: normalizedDomain,
 84                    currentOwnership: currentOwnership,
 85                    historyEntries: historyEntries
 86                )
 87                let externalEvents = await self.fetchOwnershipHistory(domain: normalizedDomain, configuration: configuration)
 88                return .ownershipHistory(Self.mergeOwnershipHistory(localEvents: localEvents, externalEvents: externalEvents))
 89            }
 90        )
 91    }
 92
 93    func dnsHistory(
 94        domain: String,
 95        dnsSections: [DNSSection],
 96        historyEntries: [HistoryEntry]
 97    ) async -> CachedLookupResult<ServiceResult<[DNSHistoryEvent]>> {
 98        let normalizedDomain = Self.normalize(domain)
 99        return await execute(
100            key: .dnsHistory(normalizedDomain),
101            rateLimitBucket: .history,
102            extract: { payload in
103                guard case let .dnsHistory(result) = payload else { return nil }
104                return result
105            },
106            operation: { [configuration = configuration()] in
107                let localEvents = Self.localDNSHistory(
108                    domain: normalizedDomain,
109                    dnsSections: dnsSections,
110                    historyEntries: historyEntries
111                )
112                let externalEvents = await self.fetchDNSHistory(domain: normalizedDomain, configuration: configuration)
113                return .dnsHistory(Self.mergeDNSHistory(localEvents: localEvents, externalEvents: externalEvents))
114            }
115        )
116    }
117
118    func extendedSubdomains(
119        domain: String,
120        existing: [DiscoveredSubdomain]
121    ) async -> CachedLookupResult<ServiceResult<[DiscoveredSubdomain]>> {
122        let normalizedDomain = Self.normalize(domain)
123        return await execute(
124            key: .extendedSubdomains(normalizedDomain),
125            rateLimitBucket: .subdomains,
126            extract: { payload in
127                guard case let .extendedSubdomains(result) = payload else { return nil }
128                return result
129            },
130            operation: { [configuration = configuration()] in
131                var merged = existing
132                switch await SubdomainDiscoveryService.discover(for: normalizedDomain, limit: 100) {
133                case let .success(results):
134                    merged = Self.mergeSubdomains(primary: merged, additional: results.map {
135                        DiscoveredSubdomain(hostname: $0.hostname, source: $0.source ?? "crt.sh", isExtended: true)
136                    })
137                case .empty, .error:
138                    break
139                }
140
141                if let external = await self.fetchExtendedSubdomains(domain: normalizedDomain, configuration: configuration) {
142                    merged = Self.mergeSubdomains(primary: merged, additional: external)
143                }
144
145                if merged.count <= existing.count {
146                    return .extendedSubdomains(.empty("No extended subdomains available"))
147                }
148
149                let onlyExtended = merged.filter(\.isExtended)
150                return .extendedSubdomains(.success(onlyExtended.sorted { $0.hostname < $1.hostname }))
151            }
152        )
153    }
154
155    func pricing(domain: String) async -> CachedLookupResult<ServiceResult<DomainPricingInsight>> {
156        let normalizedDomain = Self.normalize(domain)
157        return await execute(
158            key: .pricing(normalizedDomain),
159            rateLimitBucket: .pricing,
160            extract: { payload in
161                guard case let .pricing(result) = payload else { return nil }
162                return result
163            },
164            operation: { [configuration = configuration()] in
165                .pricing(await self.fetchPricing(domain: normalizedDomain, configuration: configuration))
166            }
167        )
168    }
169
170    /// Checks a domain against a configured blocklist/reputation endpoint. With
171    /// no endpoint configured (the default, since DomainDig ships no bundled
172    /// third-party reputation dependency) this resolves to `.empty` and callers
173    /// treat the result as unavailable rather than "clean".
174    func reputation(domain: String) async -> CachedLookupResult<ServiceResult<DomainReputationResult>> {
175        let normalizedDomain = Self.normalize(domain)
176        return await execute(
177            key: .reputation(normalizedDomain),
178            rateLimitBucket: .reputation,
179            extract: { payload in
180                guard case let .reputation(result) = payload else { return nil }
181                return result
182            },
183            operation: { [configuration = configuration()] in
184                .reputation(await self.fetchReputation(domain: normalizedDomain, configuration: configuration))
185            }
186        )
187    }
188
189    private func execute<T>(
190        key: RequestKey,
191        rateLimitBucket: RateLimitBucket,
192        extract: @escaping (CachedPayload) -> T?,
193        operation: @escaping @Sendable () async -> CachedPayload
194    ) async -> CachedLookupResult<T> {
195        if let cachedEntry = cache[key], cachedEntry.expiresAt > Date(), let value = extract(cachedEntry.payload) {
196            return CachedLookupResult(value: value, source: .cached)
197        }
198
199        if let task = inFlight[key], let value = extract(await task.value) {
200            return CachedLookupResult(value: value, source: .mixed)
201        }
202
203        let task = Task<CachedPayload, Never> {
204            await self.enforceRateLimit(for: rateLimitBucket)
205            return await operation()
206        }
207        inFlight[key] = task
208
209        let payload = await task.value
210        cache[key] = CacheEntry(payload: payload, expiresAt: Date().addingTimeInterval(ttl))
211        inFlight[key] = nil
212
213        guard let value = extract(payload) else {
214            fatalError("ExternalDataService payload extraction mismatch")
215        }
216
217        return CachedLookupResult(value: value, source: .live)
218    }
219
220    private func enforceRateLimit(for bucket: RateLimitBucket) async {
221        let now = Date()
222        if let nextAllowed = nextAllowedAt[bucket], nextAllowed > now {
223            let delay = nextAllowed.timeIntervalSince(now)
224            if delay > 0 {
225                try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
226            }
227        }
228        nextAllowedAt[bucket] = Date().addingTimeInterval(bucket.minimumSpacing)
229    }
230
231    private func configuration() -> Configuration {
232        let defaults = UserDefaults.standard
233        return Configuration(
234            ownershipHistoryURL: defaults.string(forKey: "externalData.ownershipHistoryURL")
235                ?? Bundle.main.object(forInfoDictionaryKey: "ExternalOwnershipHistoryURL") as? String,
236            dnsHistoryURL: defaults.string(forKey: "externalData.dnsHistoryURL")
237                ?? Bundle.main.object(forInfoDictionaryKey: "ExternalDNSHistoryURL") as? String,
238            extendedSubdomainsURL: defaults.string(forKey: "externalData.extendedSubdomainsURL")
239                ?? Bundle.main.object(forInfoDictionaryKey: "ExternalExtendedSubdomainsURL") as? String,
240            pricingURL: defaults.string(forKey: "externalData.pricingURL")
241                ?? Bundle.main.object(forInfoDictionaryKey: "ExternalPricingURL") as? String,
242            reputationURL: defaults.string(forKey: "externalData.reputationURL")
243                ?? Bundle.main.object(forInfoDictionaryKey: "ExternalReputationURL") as? String
244        )
245    }
246
247    private func fetchOwnershipHistory(
248        domain: String,
249        configuration: Configuration
250    ) async -> [DomainOwnershipHistoryEvent] {
251        guard let template = configuration.ownershipHistoryURL,
252              let url = Self.url(from: template, domain: domain) else {
253            return []
254        }
255
256        switch await requestData(url: url) {
257        case let .success(data):
258            return parseOwnershipHistoryEvents(from: data)
259        case .empty, .error:
260            return []
261        }
262    }
263
264    private func fetchDNSHistory(
265        domain: String,
266        configuration: Configuration
267    ) async -> [DNSHistoryEvent] {
268        guard let template = configuration.dnsHistoryURL,
269              let url = Self.url(from: template, domain: domain) else {
270            return []
271        }
272
273        switch await requestData(url: url) {
274        case let .success(data):
275            return parseDNSHistoryEvents(from: data)
276        case .empty, .error:
277            return []
278        }
279    }
280
281    private func fetchExtendedSubdomains(
282        domain: String,
283        configuration: Configuration
284    ) async -> [DiscoveredSubdomain]? {
285        guard let template = configuration.extendedSubdomainsURL,
286              let url = Self.url(from: template, domain: domain) else {
287            return nil
288        }
289
290        switch await requestData(url: url) {
291        case let .success(data):
292            return parseSubdomains(from: data)
293        case .empty, .error:
294            return nil
295        }
296    }
297
298    private func fetchPricing(
299        domain: String,
300        configuration: Configuration
301    ) async -> ServiceResult<DomainPricingInsight> {
302        guard let template = configuration.pricingURL,
303              let url = Self.url(from: template, domain: domain) else {
304            return .empty("External pricing unavailable")
305        }
306
307        switch await requestData(url: url) {
308        case let .success(data):
309            guard let pricing = parsePricing(from: data) else {
310                return .error("Invalid external response")
311            }
312            return .success(pricing)
313        case let .empty(message):
314            return .empty(message)
315        case let .error(message):
316            return .error(message)
317        }
318    }
319
320    private func fetchReputation(
321        domain: String,
322        configuration: Configuration
323    ) async -> ServiceResult<DomainReputationResult> {
324        guard let template = configuration.reputationURL,
325              let url = Self.url(from: template, domain: domain) else {
326            return .empty("No reputation source configured")
327        }
328
329        switch await requestData(url: url) {
330        case let .success(data):
331            guard let reputation = parseReputation(from: data) else {
332                return .error("Invalid external response")
333            }
334            return .success(reputation)
335        case let .empty(message):
336            return .empty(message)
337        case let .error(message):
338            return .error(message)
339        }
340    }
341
342    private func requestData(url: URL) async -> ServiceResult<Data> {
343        for attempt in 0..<2 {
344            do {
345                let (data, response) = try await session.data(for: URLRequest(url: url, timeoutInterval: 8))
346                guard let httpResponse = response as? HTTPURLResponse else {
347                    return .error("External data unavailable")
348                }
349
350                switch httpResponse.statusCode {
351                case 200:
352                    return .success(data)
353                case 204, 404:
354                    return .empty("No external data available")
355                case 429:
356                    return .error("Rate limit reached")
357                case 500...599 where attempt == 0:
358                    continue
359                default:
360                    return .error("External provider failed")
361                }
362            } catch is DecodingError {
363                return .error("Invalid external response")
364            } catch {
365                if attempt == 0 {
366                    continue
367                }
368                return .error(error.localizedDescription)
369            }
370        }
371
372        return .error("External provider failed")
373    }
374
375    private func parseOwnershipHistoryEvents(from data: Data) -> [DomainOwnershipHistoryEvent] {
376        guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
377              let rawEvents = json["events"] as? [[String: Any]] else {
378            return []
379        }
380
381        return rawEvents.compactMap { event in
382            guard let dateString = event["date"] as? String,
383                  let date = Self.iso8601DateFormatter.date(from: dateString) else {
384                return nil
385            }
386
387            return DomainOwnershipHistoryEvent(
388                date: date,
389                summary: event["summary"] as? String ?? "Ownership change observed",
390                registrar: event["registrar"] as? String,
391                registrant: event["registrant"] as? String,
392                nameservers: event["nameservers"] as? [String] ?? [],
393                source: event["source"] as? String ?? "Configured external history feed",
394                isExternal: true
395            )
396        }
397    }
398
399    private func parseDNSHistoryEvents(from data: Data) -> [DNSHistoryEvent] {
400        guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
401              let rawEvents = json["events"] as? [[String: Any]] else {
402            return []
403        }
404
405        return rawEvents.compactMap { event in
406            guard let dateString = event["date"] as? String,
407                  let date = Self.iso8601DateFormatter.date(from: dateString) else {
408                return nil
409            }
410
411            return DNSHistoryEvent(
412                date: date,
413                summary: event["summary"] as? String ?? "DNS change observed",
414                aRecords: event["a_records"] as? [String] ?? [],
415                nameservers: event["nameservers"] as? [String] ?? [],
416                recordSnapshots: parseDNSRecordSnapshots(from: event),
417                changedRecordTypes: parseDNSChangedRecordTypes(from: event),
418                source: event["source"] as? String ?? "Configured external history feed",
419                isExternal: true
420            )
421        }
422    }
423
424    private func parseSubdomains(from data: Data) -> [DiscoveredSubdomain] {
425        guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
426              let rawSubdomains = json["subdomains"] as? [[String: Any]] else {
427            return []
428        }
429
430        return rawSubdomains.compactMap { item in
431            guard let hostname = item["hostname"] as? String else { return nil }
432            return DiscoveredSubdomain(
433                hostname: hostname,
434                source: item["source"] as? String ?? "Configured external subdomain feed",
435                isExtended: true
436            )
437        }
438    }
439
440    private func parsePricing(from data: Data) -> DomainPricingInsight? {
441        guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
442            return nil
443        }
444
445        return DomainPricingInsight(
446            estimatedPrice: json["estimated_price"] as? String,
447            premiumIndicator: json["premium"] as? Bool,
448            resaleSignal: json["resale_signal"] as? String,
449            auctionSignal: json["auction_signal"] as? String,
450            source: json["source"] as? String ?? "Configured external pricing feed",
451            collectedAt: Date()
452        )
453    }
454
455    private func parseReputation(from data: Data) -> DomainReputationResult? {
456        guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
457            return nil
458        }
459
460        let listedSources = json["listed_sources"] as? [String] ?? []
461        let status: DomainReputationStatus
462        if let statusString = json["status"] as? String, let parsed = DomainReputationStatus(rawValue: statusString) {
463            status = parsed
464        } else {
465            status = listedSources.isEmpty ? .clean : .listed
466        }
467
468        return DomainReputationResult(status: status, listedSources: listedSources, checkedAt: Date())
469    }
470
471    private static func localOwnershipHistory(
472        domain: String,
473        currentOwnership: DomainOwnership?,
474        historyEntries: [HistoryEntry]
475    ) -> [DomainOwnershipHistoryEvent] {
476        let domainHistory = historyEntries
477            .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
478            .sorted { $0.timestamp < $1.timestamp }
479
480        var events: [DomainOwnershipHistoryEvent] = []
481        var previousOwnership: DomainOwnership?
482
483        for entry in domainHistory {
484            guard let ownership = entry.ownership else { continue }
485            let summary = ownershipSummaryChange(previous: previousOwnership, current: ownership)
486            if let summary {
487                events.append(
488                    DomainOwnershipHistoryEvent(
489                        date: entry.timestamp,
490                        summary: summary,
491                        registrar: ownership.registrar,
492                        registrant: ownership.registrant,
493                        nameservers: ownership.nameservers,
494                        source: "Local observations",
495                        isExternal: false
496                    )
497                )
498            }
499            previousOwnership = ownership
500        }
501
502        if let currentOwnership, events.isEmpty {
503            events.append(
504                DomainOwnershipHistoryEvent(
505                    date: Date(),
506                    summary: "Current ownership snapshot",
507                    registrar: currentOwnership.registrar,
508                    registrant: currentOwnership.registrant,
509                    nameservers: currentOwnership.nameservers,
510                    source: "Local observations",
511                    isExternal: false
512                )
513            )
514        }
515
516        return events.sorted { $0.date > $1.date }
517    }
518
519    private static func localDNSHistory(
520        domain: String,
521        dnsSections: [DNSSection],
522        historyEntries: [HistoryEntry]
523    ) -> [DNSHistoryEvent] {
524        let domainHistory = historyEntries
525            .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
526            .sorted { $0.timestamp < $1.timestamp }
527
528        var events: [DNSHistoryEvent] = []
529        var previousRecordValues: [DNSRecordType: [String]] = [:]
530
531        for entry in domainHistory {
532            let currentRecordValues = Self.historyRecordValues(in: entry.dnsSections)
533            let changedRecordTypes = Self.changedRecordTypes(previous: previousRecordValues, current: currentRecordValues)
534            let summary = dnsSummaryChange(previous: previousRecordValues, current: currentRecordValues)
535
536            if let summary {
537                events.append(
538                    DNSHistoryEvent(
539                        date: entry.timestamp,
540                        summary: summary,
541                        aRecords: currentRecordValues[.A] ?? [],
542                        nameservers: currentRecordValues[.NS] ?? [],
543                        recordSnapshots: currentRecordValues.map { DNSHistoryRecordSnapshot(recordType: $0.key, values: $0.value) }
544                            .sorted { $0.recordType.rawValue < $1.recordType.rawValue },
545                        changedRecordTypes: changedRecordTypes,
546                        source: "Local observations",
547                        isExternal: false
548                    )
549                )
550            }
551
552            previousRecordValues = currentRecordValues
553        }
554
555        if events.isEmpty {
556            let currentRecordValues = Self.historyRecordValues(in: dnsSections)
557            if !currentRecordValues.isEmpty {
558                events.append(
559                    DNSHistoryEvent(
560                        date: Date(),
561                        summary: "Current DNS snapshot",
562                        aRecords: currentRecordValues[.A] ?? [],
563                        nameservers: currentRecordValues[.NS] ?? [],
564                        recordSnapshots: currentRecordValues.map { DNSHistoryRecordSnapshot(recordType: $0.key, values: $0.value) }
565                            .sorted { $0.recordType.rawValue < $1.recordType.rawValue },
566                        changedRecordTypes: Array(currentRecordValues.keys).sorted { $0.rawValue < $1.rawValue },
567                        source: "Local observations",
568                        isExternal: false
569                    )
570                )
571            }
572        }
573
574        return events.sorted { $0.date > $1.date }
575    }
576
577    private static func mergeOwnershipHistory(
578        localEvents: [DomainOwnershipHistoryEvent],
579        externalEvents: [DomainOwnershipHistoryEvent]
580    ) -> ServiceResult<[DomainOwnershipHistoryEvent]> {
581        let merged = (externalEvents + localEvents)
582            .sorted { $0.date > $1.date }
583            .reduce(into: [DomainOwnershipHistoryEvent]()) { partialResult, event in
584                let duplicate = partialResult.contains {
585                    $0.date == event.date
586                        && $0.summary == event.summary
587                        && $0.registrar == event.registrar
588                        && $0.nameservers == event.nameservers
589                }
590                if !duplicate {
591                    partialResult.append(event)
592                }
593            }
594
595        return merged.isEmpty ? .empty("No ownership history available") : .success(merged)
596    }
597
598    private static func mergeDNSHistory(
599        localEvents: [DNSHistoryEvent],
600        externalEvents: [DNSHistoryEvent]
601    ) -> ServiceResult<[DNSHistoryEvent]> {
602        let merged = (externalEvents + localEvents)
603            .sorted { $0.date > $1.date }
604            .reduce(into: [DNSHistoryEvent]()) { partialResult, event in
605                let duplicate = partialResult.contains {
606                    $0.date == event.date
607                        && $0.summary == event.summary
608                        && compareDNSRecordSnapshots($0.recordSnapshots, event.recordSnapshots)
609                }
610                if !duplicate {
611                    partialResult.append(event)
612                }
613            }
614
615        return merged.isEmpty ? .empty("No DNS history available") : .success(merged)
616    }
617
618    private static func mergeSubdomains(
619        primary: [DiscoveredSubdomain],
620        additional: [DiscoveredSubdomain]
621    ) -> [DiscoveredSubdomain] {
622        var seen = Set(primary.map { $0.hostname.lowercased() })
623        var merged = primary
624
625        for subdomain in additional where seen.insert(subdomain.hostname.lowercased()).inserted {
626            merged.append(subdomain)
627        }
628
629        return merged.sorted { $0.hostname < $1.hostname }
630    }
631
632    private static func ownershipSummaryChange(previous: DomainOwnership?, current: DomainOwnership) -> String? {
633        guard let previous else {
634            return "Initial ownership observation"
635        }
636
637        var changes: [String] = []
638        if previous.registrar != current.registrar {
639            changes.append("Registrar changed")
640        }
641        if previous.registrant != current.registrant, current.registrant != nil {
642            changes.append("Ownership changed")
643        }
644        if previous.nameservers != current.nameservers {
645            changes.append("Nameservers changed")
646        }
647
648        return changes.isEmpty ? nil : changes.joined(separator: "")
649    }
650
651    private static func dnsSummaryChange(
652        previous: [DNSRecordType: [String]],
653        current: [DNSRecordType: [String]]
654    ) -> String? {
655        var changes: [String] = []
656        for type in [DNSRecordType.A, .AAAA, .MX, .NS, .TXT, .CNAME] {
657            let previousValues = previous[type] ?? []
658            let currentValues = current[type] ?? []
659            if previousValues != currentValues, !currentValues.isEmpty {
660                changes.append("\(type.rawValue) records changed")
661            }
662        }
663        if previous.isEmpty && !current.isEmpty {
664            changes.append("Initial DNS observation")
665        }
666        return changes.isEmpty ? nil : changes.joined(separator: "")
667    }
668
669    private static func url(from template: String, domain: String) -> URL? {
670        URL(string: template.replacingOccurrences(of: "{domain}", with: domain))
671    }
672
673    private static func normalize(_ domain: String) -> String {
674        domain.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
675    }
676
677    private static func dnsValues(for type: DNSRecordType, in sections: [DNSSection]) -> [String] {
678        sections
679            .first(where: { $0.recordType == type })?
680            .records
681            .map(\.value)
682            .sorted() ?? []
683    }
684
685    private func parseDNSRecordSnapshots(from event: [String: Any]) -> [DNSHistoryRecordSnapshot] {
686        if let snapshots = event["record_snapshots"] as? [[String: Any]] {
687            return snapshots.compactMap { item in
688                guard let typeName = item["type"] as? String,
689                      let type = DNSRecordType(rawValue: typeName) else {
690                    return nil
691                }
692                return DNSHistoryRecordSnapshot(recordType: type, values: item["values"] as? [String] ?? [])
693            }
694        }
695        var snapshots: [DNSHistoryRecordSnapshot] = []
696        if let aRecords = event["a_records"] as? [String], !aRecords.isEmpty {
697            snapshots.append(DNSHistoryRecordSnapshot(recordType: .A, values: aRecords))
698        }
699        if let nameservers = event["nameservers"] as? [String], !nameservers.isEmpty {
700            snapshots.append(DNSHistoryRecordSnapshot(recordType: .NS, values: nameservers))
701        }
702        return snapshots
703    }
704
705    private func parseDNSChangedRecordTypes(from event: [String: Any]) -> [DNSRecordType] {
706        if let rawTypes = event["changed_record_types"] as? [String] {
707            return rawTypes.compactMap(DNSRecordType.init(rawValue:))
708        }
709        return parseDNSRecordSnapshots(from: event).map(\.recordType)
710    }
711
712    private static func historyRecordValues(in sections: [DNSSection]) -> [DNSRecordType: [String]] {
713        let trackedTypes: [DNSRecordType] = [.A, .AAAA, .MX, .NS, .TXT, .CNAME]
714        return trackedTypes.reduce(into: [DNSRecordType: [String]]()) { result, type in
715            let values = dnsValues(for: type, in: sections)
716            if !values.isEmpty {
717                result[type] = values
718            }
719        }
720    }
721
722    private static func changedRecordTypes(
723        previous: [DNSRecordType: [String]],
724        current: [DNSRecordType: [String]]
725    ) -> [DNSRecordType] {
726        Array(Set(previous.keys).union(current.keys))
727            .filter { previous[$0] != current[$0] }
728            .sorted { $0.rawValue < $1.rawValue }
729    }
730
731    private static func compareDNSRecordSnapshots(
732        _ lhs: [DNSHistoryRecordSnapshot],
733        _ rhs: [DNSHistoryRecordSnapshot]
734    ) -> Bool {
735        guard lhs.count == rhs.count else { return false }
736        return zip(lhs, rhs).allSatisfy { left, right in
737            left.recordType == right.recordType && left.values == right.values
738        }
739    }
740
741    // ISO8601DateFormatter is documented thread-safe ("ISO8601DateFormatter is
742    // thread-safe"  Apple docs), so sharing one instance across contexts is
743    // sound; the annotation records that the compiler cannot see it. (#27)
744    private nonisolated(unsafe) static let iso8601DateFormatter: ISO8601DateFormatter = {
745        let formatter = ISO8601DateFormatter()
746        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
747        return formatter
748    }()
749}