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}