krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v5.0.1: DomainDig/DiffService.swift · raw
1import Foundation
2
3enum DiffChangeType: String, Codable {
4 case added
5 case removed
6 case changed
7 case unchanged
8
9 var marker: String {
10 switch self {
11 case .added:
12 return "+"
13 case .removed:
14 return "-"
15 case .changed:
16 return "~"
17 case .unchanged:
18 return "="
19 }
20 }
21
22 var title: String {
23 switch self {
24 case .added:
25 return "Added"
26 case .removed:
27 return "Removed"
28 case .changed:
29 return "Changed"
30 case .unchanged:
31 return "Unchanged"
32 }
33 }
34}
35
36struct DiffItem: Identifiable, Equatable, Codable {
37 let id: String
38 let label: String
39 let changeType: DiffChangeType
40 let oldValue: String?
41 let newValue: String?
42 let severity: ChangeSeverity
43
44 init(
45 id: String,
46 label: String,
47 changeType: DiffChangeType,
48 oldValue: String?,
49 newValue: String?,
50 severity: ChangeSeverity
51 ) {
52 self.id = id
53 self.label = label
54 self.changeType = changeType
55 self.oldValue = oldValue
56 self.newValue = newValue
57 self.severity = severity
58 }
59
60 var hasChanges: Bool {
61 changeType != .unchanged
62 }
63}
64
65struct DiffSection: Identifiable, Equatable, Codable {
66 let id: String
67 let title: String
68 let items: [DiffItem]
69
70 var hasChanges: Bool {
71 items.contains(where: \.hasChanges)
72 }
73
74 var severity: ChangeSeverity {
75 items.map(\.severity).max() ?? .low
76 }
77
78 var changeCount: Int {
79 items.filter(\.hasChanges).count
80 }
81}
82
83struct DomainDiff: Identifiable, Equatable, Codable {
84 let domain: String
85 let fromTimestamp: Date
86 let toTimestamp: Date
87 let sections: [DiffSection]
88 let changedSectionIDs: [String]
89 let changedSectionTitles: [String]
90 let contextNote: String?
91
92 var id: String {
93 "\(domain)-\(fromTimestamp.timeIntervalSince1970)-\(toTimestamp.timeIntervalSince1970)"
94 }
95
96 var changeCount: Int {
97 sections.reduce(0) { $0 + $1.changeCount }
98 }
99
100 var severity: ChangeSeverity {
101 sections.map(\.severity).max() ?? .low
102 }
103}
104
105typealias DomainDiffItem = DiffItem
106typealias DomainDiffSection = DiffSection
107
108/// Result of comparing two distinct domains' latest reports side by side, as
109/// opposed to `DomainDiff`, which compares the same domain across time.
110struct DomainComparisonResult: Identifiable, Equatable {
111 let domainA: String
112 let domainB: String
113 let generatedAt: Date
114 let sections: [DiffSection]
115 let contextNote: String?
116
117 var id: String {
118 "\(domainA)-\(domainB)-\(generatedAt.timeIntervalSince1970)"
119 }
120
121 var changedSections: [DiffSection] {
122 sections.filter(\.hasChanges)
123 }
124
125 var changeCount: Int {
126 sections.reduce(0) { $0 + $1.changeCount }
127 }
128
129 var severity: ChangeSeverity {
130 sections.map(\.severity).max() ?? .low
131 }
132}
133
134enum DiffService {
135 static func compare(from oldReport: DomainReport, to newReport: DomainReport) -> DomainDiff {
136 let sections = [
137 availabilitySection(from: oldReport, to: newReport),
138 ownershipSection(from: oldReport, to: newReport),
139 dnsSection(from: oldReport, to: newReport),
140 webSection(from: oldReport, to: newReport),
141 emailSection(from: oldReport, to: newReport),
142 networkSection(from: oldReport, to: newReport),
143 subdomainsSection(from: oldReport, to: newReport),
144 intelligenceSection(from: oldReport, to: newReport),
145 riskSection(from: oldReport, to: newReport)
146 ]
147
148 let changedSections = sections.filter(\.hasChanges)
149 return DomainDiff(
150 domain: newReport.domain,
151 fromTimestamp: oldReport.timestamp,
152 toTimestamp: newReport.timestamp,
153 sections: sections,
154 changedSectionIDs: changedSections.map(\.id),
155 changedSectionTitles: changedSections.map(\.title),
156 contextNote: comparisonContextNote(from: oldReport, to: newReport)
157 )
158 }
159
160 static func compare(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiff {
161 let builder = DomainReportBuilder()
162 let oldReport = builder.build(from: oldSnapshot, deriveChangeSummary: false)
163 let newReport = builder.build(from: newSnapshot, previousSnapshot: oldSnapshot, deriveChangeSummary: false)
164 return compare(from: oldReport, to: newReport)
165 }
166
167 static func summary(
168 from oldSnapshot: LookupSnapshot,
169 to newSnapshot: LookupSnapshot,
170 generatedAt: Date = Date(),
171 riskAssessment: DomainRiskAssessment? = nil,
172 insights: [String]? = nil
173 ) -> DomainChangeSummary {
174 let diff = compare(from: oldSnapshot, to: newSnapshot)
175 let changedItems = diff.sections.flatMap(\.items).filter(\.hasChanges)
176 let highlights = diff.changedSectionTitles
177 let severity = changedItems.map(\.severity).max() ?? .low
178 let message = summaryMessage(from: highlights, changeCount: changedItems.count)
179 let observedFacts = changedItems.prefix(4).map { item in
180 "\(item.label): \(item.oldValue ?? "none") -> \(item.newValue ?? "none")"
181 }
182
183 let analysis = DomainInsightEngine.analyze(snapshot: newSnapshot, previousSnapshot: oldSnapshot)
184 let currentRiskAssessment = riskAssessment ?? analysis.riskAssessment
185 let currentInsights = insights ?? analysis.insights
186 let previousRiskScore = DomainInsightEngine.analyze(snapshot: oldSnapshot).riskAssessment.score
187 let riskScoreDelta = currentRiskAssessment.score - previousRiskScore
188 let impactClassification = DomainInsightEngine.impactClassification(
189 severity: severity,
190 riskDelta: riskScoreDelta,
191 changedSections: highlights
192 )
193
194 return DomainChangeSummary(
195 hasChanges: !changedItems.isEmpty,
196 changedSections: highlights,
197 message: message,
198 severity: severity,
199 impactClassification: impactClassification,
200 generatedAt: generatedAt,
201 observedFacts: observedFacts,
202 inferredConclusions: highlights.isEmpty ? [] : [message],
203 contextNote: diff.contextNote,
204 riskAssessment: currentRiskAssessment,
205 insights: currentInsights,
206 riskScoreDelta: riskScoreDelta
207 )
208 }
209
210 /// Compares two distinct domains' reports section by section. Reuses the same
211 /// field-level diff logic as time-based comparison; the two reports are simply
212 /// unrelated domains rather than the same domain at different times.
213 static func compare(domainA: DomainReport, domainB: DomainReport) -> DomainComparisonResult {
214 let sections = [
215 availabilitySection(from: domainA, to: domainB),
216 ownershipSection(from: domainA, to: domainB),
217 dnsSection(from: domainA, to: domainB),
218 webSection(from: domainA, to: domainB),
219 emailSection(from: domainA, to: domainB),
220 networkSection(from: domainA, to: domainB),
221 subdomainsSection(from: domainA, to: domainB),
222 intelligenceSection(from: domainA, to: domainB),
223 riskSection(from: domainA, to: domainB)
224 ]
225
226 return DomainComparisonResult(
227 domainA: domainA.domain,
228 domainB: domainB.domain,
229 generatedAt: Date(),
230 sections: sections,
231 contextNote: comparisonContextNote(from: domainA, to: domainB)
232 )
233 }
234
235 static func comparisonContextNote(from oldReport: DomainReport, to newReport: DomainReport) -> String? {
236 var notes: [String] = []
237 if oldReport.resolverURLString != newReport.resolverURLString {
238 notes.append("Compared snapshots used different DNS resolvers.")
239 }
240 if oldReport.resultSource != newReport.resultSource {
241 notes.append("Compared snapshots came from different collection modes.")
242 }
243 return notes.isEmpty ? nil : notes.joined(separator: " ")
244 }
245
246 static func comparisonContextNote(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> String? {
247 comparisonContextNote(
248 from: DomainReportBuilder().build(from: oldSnapshot, deriveChangeSummary: false),
249 to: DomainReportBuilder().build(from: newSnapshot, previousSnapshot: oldSnapshot, deriveChangeSummary: false)
250 )
251 }
252
253 static func certificateWarningLevel(for snapshot: LookupSnapshot) -> CertificateWarningLevel {
254 guard let days = snapshot.sslInfo?.daysUntilExpiry else {
255 return .none
256 }
257 if days < 14 {
258 return .critical
259 }
260 if days < 30 {
261 return .warning
262 }
263 return .none
264 }
265
266 private static func availabilitySection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
267 DiffSection(
268 id: "availability",
269 title: "Domain / Availability",
270 items: [
271 compare(id: "domain", label: "Domain", oldValue: oldReport.domain, newValue: newReport.domain, severity: .low),
272 compare(
273 id: "availability",
274 label: "Availability",
275 oldValue: availabilityLabel(oldReport.availability),
276 newValue: availabilityLabel(newReport.availability),
277 severity: .high
278 ),
279 compare(id: "primary-ip", label: "Primary IP", oldValue: oldReport.dns.primaryIP, newValue: newReport.dns.primaryIP, severity: .high),
280 compare(
281 id: "tls-status",
282 label: "TLS Status",
283 oldValue: oldReport.web.tlsStatus,
284 newValue: newReport.web.tlsStatus,
285 severity: .medium
286 )
287 ].compactMap { $0 }
288 )
289 }
290
291 private static func ownershipSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
292 DiffSection(
293 id: "ownership",
294 title: "Ownership",
295 items: [
296 compare(id: "registrar", label: "Registrar", oldValue: oldReport.ownership?.registrar, newValue: newReport.ownership?.registrar, severity: .high),
297 compare(id: "registrant", label: "Registrant", oldValue: oldReport.ownership?.registrant, newValue: newReport.ownership?.registrant, severity: .medium),
298 compare(
299 id: "ownership-created",
300 label: "Registration Date",
301 oldValue: ownershipDateLabel(oldReport.ownership?.createdDate),
302 newValue: ownershipDateLabel(newReport.ownership?.createdDate),
303 severity: .low
304 ),
305 compare(
306 id: "ownership-expires",
307 label: "Expiration Date",
308 oldValue: ownershipDateLabel(oldReport.ownership?.expirationDate),
309 newValue: ownershipDateLabel(newReport.ownership?.expirationDate),
310 severity: .medium
311 ),
312 compare(
313 id: "ownership-status",
314 label: "Status",
315 oldValue: joined(oldReport.ownership?.status),
316 newValue: joined(newReport.ownership?.status),
317 severity: .low
318 ),
319 compare(
320 id: "ownership-nameservers",
321 label: "Nameservers",
322 oldValue: joined(oldReport.ownership?.nameservers),
323 newValue: joined(newReport.ownership?.nameservers),
324 severity: .medium
325 ),
326 compare(id: "ownership-abuse", label: "Abuse Contact", oldValue: oldReport.ownership?.abuseEmail, newValue: newReport.ownership?.abuseEmail, severity: .low)
327 ].compactMap { $0 }
328 )
329 }
330
331 private static func dnsSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
332 let oldSections = Dictionary(uniqueKeysWithValues: oldReport.dns.recordSections.map { ($0.recordType, $0) })
333 let newSections = Dictionary(uniqueKeysWithValues: newReport.dns.recordSections.map { ($0.recordType, $0) })
334 let recordTypes = Set(oldSections.keys).union(newSections.keys).sorted { $0.rawValue < $1.rawValue }
335
336 var items: [DiffItem] = [
337 compare(id: "dnssec", label: "DNSSEC", oldValue: dnssecLabel(oldReport.dns.dnssecSigned), newValue: dnssecLabel(newReport.dns.dnssecSigned), severity: .medium),
338 compare(id: "ptr", label: "PTR", oldValue: oldReport.dns.ptrRecord, newValue: newReport.dns.ptrRecord, severity: .low)
339 ].compactMap { $0 }
340
341 for type in recordTypes {
342 items.append(
343 compare(
344 id: "dns-\(type.rawValue.lowercased())-records",
345 label: "\(type.rawValue) Records",
346 oldValue: normalizedRecordValues(for: oldSections[type]),
347 newValue: normalizedRecordValues(for: newSections[type]),
348 severity: type == .A || type == .NS ? .high : .medium
349 ) ?? DiffItem(id: "", label: "", changeType: .unchanged, oldValue: nil, newValue: nil, severity: .low)
350 )
351 if let ttlChange = compare(
352 id: "dns-\(type.rawValue.lowercased())-ttl",
353 label: "\(type.rawValue) TTL",
354 oldValue: normalizedTTLValues(for: oldSections[type]),
355 newValue: normalizedTTLValues(for: newSections[type]),
356 severity: .low
357 ) {
358 items.append(ttlChange)
359 }
360 }
361
362 return DiffSection(
363 id: "dns",
364 title: "DNS",
365 items: items.filter { !$0.id.isEmpty }
366 )
367 }
368
369 private static func webSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
370 DiffSection(
371 id: "web",
372 title: "Web",
373 items: [
374 compare(id: "web-status", label: "HTTP Status", oldValue: oldReport.web.statusCode.map(String.init), newValue: newReport.web.statusCode.map(String.init), severity: .medium),
375 compare(id: "web-grade", label: "Security Grade", oldValue: oldReport.web.securityGrade, newValue: newReport.web.securityGrade, severity: .medium),
376 compare(id: "web-final-url", label: "Final URL", oldValue: oldReport.web.finalURL, newValue: newReport.web.finalURL, severity: .high),
377 compare(id: "web-tls-issuer", label: "TLS Issuer", oldValue: oldReport.web.tls?.issuer, newValue: newReport.web.tls?.issuer, severity: .medium),
378 compare(id: "web-tls-expiry", label: "TLS Expiration", oldValue: expirationLabel(oldReport.web.tls), newValue: expirationLabel(newReport.web.tls), severity: .medium),
379 compare(id: "web-headers", label: "Headers", oldValue: normalizedHeaders(oldReport.web.headers), newValue: normalizedHeaders(newReport.web.headers), severity: .low),
380 compare(id: "web-redirects", label: "Redirect Chain", oldValue: redirectChainSummary(oldReport.web.redirectChain), newValue: redirectChainSummary(newReport.web.redirectChain), severity: .medium)
381 ].compactMap { $0 }
382 )
383 }
384
385 private static func emailSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
386 DiffSection(
387 id: "email",
388 title: "Email Security",
389 items: [
390 compare(id: "email-summary", label: "Summary", oldValue: oldReport.email.summary, newValue: newReport.email.summary, severity: .medium),
391 compare(id: "email-grade", label: "Grade", oldValue: oldReport.email.grade?.rawValue, newValue: newReport.email.grade?.rawValue, severity: .medium),
392 compare(id: "email-spf", label: "SPF", oldValue: recordLabel(oldReport.email.records?.spf), newValue: recordLabel(newReport.email.records?.spf), severity: .medium),
393 compare(id: "email-dmarc", label: "DMARC", oldValue: recordLabel(oldReport.email.records?.dmarc), newValue: recordLabel(newReport.email.records?.dmarc), severity: .high),
394 compare(id: "email-dkim", label: "DKIM", oldValue: recordLabel(oldReport.email.records?.dkim), newValue: recordLabel(newReport.email.records?.dkim), severity: .medium),
395 compare(id: "email-bimi", label: "BIMI", oldValue: recordLabel(oldReport.email.records?.bimi), newValue: recordLabel(newReport.email.records?.bimi), severity: .low),
396 compare(id: "email-mta-sts", label: "MTA-STS", oldValue: mtaStsLabel(oldReport.email.records?.mtaSts), newValue: mtaStsLabel(newReport.email.records?.mtaSts), severity: .medium)
397 ].compactMap { $0 }
398 )
399 }
400
401 private static func networkSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
402 DiffSection(
403 id: "network",
404 title: "Network",
405 items: [
406 compare(id: "network-reachability", label: "Reachability", oldValue: oldReport.network.reachabilitySummary, newValue: newReport.network.reachabilitySummary, severity: .medium),
407 compare(id: "network-geolocation", label: "Geolocation", oldValue: oldReport.network.geolocationSummary, newValue: newReport.network.geolocationSummary, severity: .medium),
408 compare(id: "network-open-ports", label: "Open Ports", oldValue: joined(oldReport.network.openPorts.map(String.init)), newValue: joined(newReport.network.openPorts.map(String.init)), severity: .high),
409 compare(id: "network-port-scan", label: "Port Scan", oldValue: portScanSummary(oldReport.network.portScan), newValue: portScanSummary(newReport.network.portScan), severity: .medium)
410 ].compactMap { $0 }
411 )
412 }
413
414 private static func subdomainsSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
415 DiffSection(
416 id: "subdomains",
417 title: "Subdomains",
418 items: [
419 compare(id: "subdomains-primary", label: "Primary Subdomains", oldValue: joined(oldReport.subdomains), newValue: joined(newReport.subdomains), severity: .low),
420 compare(id: "subdomains-extended", label: "Extended Subdomains", oldValue: joined(oldReport.extendedSubdomains), newValue: joined(newReport.extendedSubdomains), severity: .low),
421 compare(id: "subdomains-groups", label: "Groups", oldValue: groupSummary(oldReport.subdomainGroups), newValue: groupSummary(newReport.subdomainGroups), severity: .low)
422 ].compactMap { $0 }
423 )
424 }
425
426 private static func riskSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
427 DiffSection(
428 id: "risk",
429 title: "Risk / Insights",
430 items: [
431 compare(id: "risk-score", label: "Risk Score", oldValue: "\(oldReport.riskAssessment.score)", newValue: "\(newReport.riskAssessment.score)", severity: .high),
432 compare(id: "risk-level", label: "Risk Level", oldValue: oldReport.riskAssessment.level.title, newValue: newReport.riskAssessment.level.title, severity: .high),
433 compare(id: "risk-factors", label: "Risk Factors", oldValue: joined(oldReport.riskAssessment.factors.map(\.description)), newValue: joined(newReport.riskAssessment.factors.map(\.description)), severity: .medium),
434 compare(id: "risk-insights", label: "Insights", oldValue: joined(oldReport.insights), newValue: joined(newReport.insights), severity: .medium)
435 ].compactMap { $0 }
436 )
437 }
438
439 private static func intelligenceSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
440 DiffSection(
441 id: "intelligence",
442 title: "Data+ Intelligence",
443 items: [
444 compare(id: "intel-provider", label: "Provider", oldValue: oldReport.inferredProvider?.name, newValue: newReport.inferredProvider?.name, severity: .medium),
445 compare(id: "intel-classification", label: "Classification", oldValue: oldReport.domainClassification?.kind.title, newValue: newReport.domainClassification?.kind.title, severity: .medium),
446 compare(id: "intel-hosting-history", label: "Hosting Transitions", oldValue: joined(oldReport.hostingTransitions.map(\.summary)), newValue: joined(newReport.hostingTransitions.map(\.summary)), severity: .medium),
447 compare(id: "intel-ownership-history", label: "Ownership Transitions", oldValue: joined(oldReport.ownershipTransitions.map(\.summary)), newValue: joined(newReport.ownershipTransitions.map(\.summary)), severity: .high),
448 compare(id: "intel-risk-signals", label: "Risk Signals", oldValue: joined(oldReport.riskSignals.map(\.title)), newValue: joined(newReport.riskSignals.map(\.title)), severity: .medium)
449 ].compactMap { $0 }
450 )
451 }
452
453 private static func compare(
454 id: String,
455 label: String,
456 oldValue: String?,
457 newValue: String?,
458 severity: ChangeSeverity
459 ) -> DiffItem? {
460 let oldValue = normalized(oldValue)
461 let newValue = normalized(newValue)
462
463 guard oldValue != nil || newValue != nil else {
464 return nil
465 }
466
467 let changeType: DiffChangeType
468 switch (oldValue?.lowercased(), newValue?.lowercased()) {
469 case let (old?, new?) where old == new:
470 changeType = .unchanged
471 case (nil, _?):
472 changeType = .added
473 case (_?, nil):
474 changeType = .removed
475 default:
476 changeType = .changed
477 }
478
479 return DiffItem(
480 id: id,
481 label: label,
482 changeType: changeType,
483 oldValue: oldValue,
484 newValue: newValue,
485 severity: severity
486 )
487 }
488
489 static func summaryMessage(from sectionTitles: [String], changeCount: Int) -> String {
490 guard !sectionTitles.isEmpty else {
491 return "No meaningful changes"
492 }
493 if sectionTitles.count == 1 {
494 return "\(sectionTitles[0]) changed"
495 }
496 return "\(sectionTitles[0]) and \(sectionTitles[1].lowercased()) changed (\(changeCount) items)"
497 }
498
499 private static func normalized(_ value: String?) -> String? {
500 guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
501 return nil
502 }
503 return value
504 }
505
506 private static func availabilityLabel(_ status: DomainAvailabilityStatus) -> String {
507 switch status {
508 case .available:
509 return "Available"
510 case .registered:
511 return "Registered"
512 case .unknown:
513 return "Unknown"
514 }
515 }
516
517 private static func ownershipDateLabel(_ date: Date?) -> String? {
518 date?.formatted(date: .abbreviated, time: .omitted)
519 }
520
521 private static func expirationLabel(_ certificate: SSLCertificateInfo?) -> String? {
522 guard let certificate else { return nil }
523 return "\(certificate.validUntil.formatted(date: .abbreviated, time: .omitted)) (\(certificate.daysUntilExpiry)d)"
524 }
525
526 private static func joined(_ values: [String]?) -> String? {
527 guard let values else { return nil }
528 let normalizedValues = values
529 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
530 .filter { !$0.isEmpty }
531 .sorted()
532 return normalizedValues.isEmpty ? nil : normalizedValues.joined(separator: ", ")
533 }
534
535 private static func normalizedRecordValues(for section: DNSSection?) -> String? {
536 guard let section else { return nil }
537 let values = (section.records + section.wildcardRecords)
538 .map(\.value)
539 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
540 .sorted()
541 return values.isEmpty ? nil : values.joined(separator: ", ")
542 }
543
544 private static func normalizedTTLValues(for section: DNSSection?) -> String? {
545 guard let section else { return nil }
546 let values = (section.records + section.wildcardRecords)
547 .map { "\($0.value.lowercased()):\($0.ttl)" }
548 .sorted()
549 return values.isEmpty ? nil : values.joined(separator: ", ")
550 }
551
552 private static func normalizedHeaders(_ headers: [HTTPHeader]) -> String? {
553 let values = headers
554 .map { "\($0.name.lowercased()): \($0.value.trimmingCharacters(in: .whitespacesAndNewlines))" }
555 .sorted()
556 return values.isEmpty ? nil : values.joined(separator: " | ")
557 }
558
559 private static func redirectChainSummary(_ redirects: [RedirectHop]) -> String? {
560 let values = redirects.map { "\($0.statusCode) \($0.url)" }
561 return values.isEmpty ? nil : values.joined(separator: " -> ")
562 }
563
564 private static func portScanSummary(_ results: [PortScanResult]) -> String? {
565 let values = results
566 .sorted { $0.port < $1.port }
567 .map { "\($0.port):\($0.open ? "open" : "closed")" }
568 return values.isEmpty ? nil : values.joined(separator: ", ")
569 }
570
571 private static func groupSummary(_ groups: [SubdomainGroup]) -> String? {
572 joined(groups.map { "\($0.label): \($0.subdomains.count)" })
573 }
574
575 private static func recordLabel(_ record: EmailSecurityRecord?) -> String? {
576 guard let record else { return nil }
577 if record.found {
578 return record.value ?? "Present"
579 }
580 return "Missing"
581 }
582
583 private static func mtaStsLabel(_ result: MTASTSResult?) -> String? {
584 guard let result else { return nil }
585 guard result.txtFound else { return "Missing" }
586 return result.policyMode ?? "Present"
587 }
588
589 private static func dnssecLabel(_ value: Bool?) -> String? {
590 switch value {
591 case true:
592 return "Signed"
593 case false:
594 return "Unsigned"
595 case nil:
596 return nil
597 }
598 }
599}
600
601enum DomainDiffService {
602 static func diff(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> [DomainDiffSection] {
603 DiffService.compare(from: oldSnapshot, to: newSnapshot).sections
604 }
605
606 static func summary(
607 from oldSnapshot: LookupSnapshot,
608 to newSnapshot: LookupSnapshot,
609 generatedAt: Date = Date(),
610 riskAssessment: DomainRiskAssessment? = nil,
611 insights: [String]? = nil
612 ) -> DomainChangeSummary {
613 DiffService.summary(
614 from: oldSnapshot,
615 to: newSnapshot,
616 generatedAt: generatedAt,
617 riskAssessment: riskAssessment,
618 insights: insights
619 )
620 }
621
622 static func comparisonContextNote(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> String? {
623 DiffService.comparisonContextNote(from: oldSnapshot, to: newSnapshot)
624 }
625
626 static func certificateWarningLevel(for snapshot: LookupSnapshot) -> CertificateWarningLevel {
627 DiffService.certificateWarningLevel(for: snapshot)
628 }
629}