krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v2.0.0: DomainDig/DomainDiffService.swift · raw
1import Foundation
2
3enum DiffChangeType: String, Codable {
4 case added
5 case removed
6 case changed
7 case unchanged
8}
9
10struct DomainDiffItem: Identifiable, Equatable {
11 let id = UUID()
12 let label: String
13 let changeType: DiffChangeType
14 let oldValue: String?
15 let newValue: String?
16}
17
18struct DomainDiffSection: Identifiable, Equatable {
19 let id = UUID()
20 let title: String
21 let items: [DomainDiffItem]
22}
23
24enum DomainDiffService {
25 static func diff(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> [DomainDiffSection] {
26 [
27 section(title: "Availability", item: compare(
28 label: "Status",
29 oldValue: availabilityLabel(oldSnapshot.availabilityResult?.status),
30 newValue: availabilityLabel(newSnapshot.availabilityResult?.status)
31 )),
32 section(title: "Primary IP", item: compare(
33 label: "Address",
34 oldValue: primaryIP(from: oldSnapshot),
35 newValue: primaryIP(from: newSnapshot)
36 )),
37 dnsSection(from: oldSnapshot, to: newSnapshot),
38 section(title: "Redirect", item: compare(
39 label: "Final Target",
40 oldValue: finalRedirectURL(from: oldSnapshot),
41 newValue: finalRedirectURL(from: newSnapshot)
42 )),
43 tlsSection(from: oldSnapshot, to: newSnapshot),
44 httpSection(from: oldSnapshot, to: newSnapshot),
45 emailSection(from: oldSnapshot, to: newSnapshot)
46 ]
47 .filter { !$0.items.isEmpty }
48 }
49
50 static func summary(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot, generatedAt: Date = Date()) -> DomainChangeSummary {
51 let changedSections = diff(from: oldSnapshot, to: newSnapshot)
52 .filter { $0.items.contains(where: { $0.changeType != .unchanged }) }
53 .map(\.title)
54
55 return DomainChangeSummary(
56 hasChanges: !changedSections.isEmpty,
57 changedSections: changedSections,
58 generatedAt: generatedAt
59 )
60 }
61
62 private static func section(title: String, item: DomainDiffItem?) -> DomainDiffSection {
63 DomainDiffSection(title: title, items: item.map { [$0] } ?? [])
64 }
65
66 private static func dnsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
67 let oldValue = normalizedDNSSummary(from: oldSnapshot)
68 let newValue = normalizedDNSSummary(from: newSnapshot)
69 return section(title: "DNS Records", item: compare(label: "Records", oldValue: oldValue, newValue: newValue))
70 }
71
72 private static func tlsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
73 var items: [DomainDiffItem] = []
74 if let item = compare(label: "Issuer", oldValue: normalized(oldSnapshot.sslInfo?.issuer), newValue: normalized(newSnapshot.sslInfo?.issuer)) {
75 items.append(item)
76 }
77 if let item = compare(label: "Certificate", oldValue: tlsSummary(from: oldSnapshot), newValue: tlsSummary(from: newSnapshot)) {
78 items.append(item)
79 }
80 return DomainDiffSection(title: "TLS Certificate", items: items)
81 }
82
83 private static func httpSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
84 var items: [DomainDiffItem] = []
85 if let item = compare(label: "HTTP Status", oldValue: httpStatusSummary(from: oldSnapshot), newValue: httpStatusSummary(from: newSnapshot)) {
86 items.append(item)
87 }
88 if let item = compare(label: "Security Grade", oldValue: normalized(oldSnapshot.httpSecurityGrade), newValue: normalized(newSnapshot.httpSecurityGrade)) {
89 items.append(item)
90 }
91 return DomainDiffSection(title: "HTTP", items: items)
92 }
93
94 private static func emailSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
95 section(
96 title: "Email Security",
97 item: compare(
98 label: "Summary",
99 oldValue: normalized(emailSummary(from: oldSnapshot)),
100 newValue: normalized(emailSummary(from: newSnapshot))
101 )
102 )
103 }
104
105 private static func compare(label: String, oldValue: String?, newValue: String?) -> DomainDiffItem? {
106 let oldValue = normalized(oldValue)
107 let newValue = normalized(newValue)
108
109 guard oldValue != nil || newValue != nil else {
110 return nil
111 }
112
113 let changeType: DiffChangeType
114 switch (oldValue, newValue) {
115 case let (old?, new?) where old == new:
116 changeType = .unchanged
117 case (nil, _?):
118 changeType = .added
119 case (_?, nil):
120 changeType = .removed
121 default:
122 changeType = .changed
123 }
124
125 return DomainDiffItem(label: label, changeType: changeType, oldValue: oldValue, newValue: newValue)
126 }
127
128 private static func normalized(_ value: String?) -> String? {
129 guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
130 return nil
131 }
132 return value.lowercased()
133 }
134
135 private static func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String? {
136 switch status {
137 case .available:
138 return "available"
139 case .registered:
140 return "registered"
141 case .unknown:
142 return "unknown"
143 case .none:
144 return nil
145 }
146 }
147
148 private static func primaryIP(from snapshot: LookupSnapshot) -> String? {
149 snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
150 }
151
152 private static func finalRedirectURL(from snapshot: LookupSnapshot) -> String? {
153 snapshot.redirectChain.last?.url
154 }
155
156 private static func tlsSummary(from snapshot: LookupSnapshot) -> String? {
157 if let sslInfo = snapshot.sslInfo {
158 return "\(sslInfo.commonName) | \(sslInfo.validUntil.formatted(date: .abbreviated, time: .omitted))"
159 }
160 return snapshot.sslError
161 }
162
163 private static func httpStatusSummary(from snapshot: LookupSnapshot) -> String? {
164 if let httpStatusCode = snapshot.httpStatusCode {
165 return "\(httpStatusCode)"
166 }
167 return snapshot.httpHeadersError
168 }
169
170 private static func emailSummary(from snapshot: LookupSnapshot) -> String? {
171 if let emailSecurity = snapshot.emailSecurity {
172 return [
173 "spf:\(emailSecurity.spf.found)",
174 "dmarc:\(emailSecurity.dmarc.found)",
175 "dkim:\(emailSecurity.dkim.found)",
176 "bimi:\(emailSecurity.bimi.found)",
177 "mta-sts:\(emailSecurity.mtaSts?.txtFound == true)"
178 ].joined(separator: "|")
179 }
180 return snapshot.emailSecurityError
181 }
182
183 private static func normalizedDNSSummary(from snapshot: LookupSnapshot) -> String? {
184 let parts = snapshot.dnsSections
185 .sorted { $0.recordType.rawValue < $1.recordType.rawValue }
186 .map { section in
187 let values = (section.records + section.wildcardRecords)
188 .map(\.value)
189 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
190 .sorted()
191 .joined(separator: ",")
192 return "\(section.recordType.rawValue):\(values)"
193 }
194 .filter { !$0.hasSuffix(":") }
195 return parts.isEmpty ? nil : parts.joined(separator: "|")
196 }
197}