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}