krz/domain-dig

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

main: DomainInspectionService.swift · raw

  1import Foundation
  2
  3struct DomainInspectionService {
  4    private let reportBuilder = DomainReportBuilder()
  5    private let runtime: LookupRuntime
  6
  7    init(runtime: LookupRuntime = .shared) {
  8        self.runtime = runtime
  9    }
 10
 11    func inspect(domain: String, previousSnapshot: LookupSnapshot? = nil) async -> DomainReport {
 12        let snapshot = await inspectSnapshot(domain: domain, previousSnapshot: previousSnapshot)
 13        return reportBuilder.build(from: snapshot)
 14    }
 15
 16    func inspectSnapshot(domain: String, previousSnapshot: LookupSnapshot? = nil) async -> LookupSnapshot {
 17        let normalizedDomain = normalize(domain)
 18        let inspectionStartedAt = DomainDebugLog.signpostStart("Inspection.inspectSnapshot", domain: normalizedDomain)
 19        let startedAt = Date()
 20        let resolverDisplayName = DNSLookupService.currentResolverDisplayName()
 21        let resolverURLString = DNSLookupService.currentResolverURLString()
 22        var cachedSections = Set<LookupSectionKind>()
 23        var sectionSources: [LookupResultSource] = []
 24        var provenanceBySection: [LookupSectionKind: SectionProvenance] = [:]
 25        var dataSources = Set<String>()
 26        var errorDetails: [LookupSectionKind: InspectionFailure] = [:]
 27
 28        async let dnsFetch = runtime.dns(domain: normalizedDomain)
 29        async let availabilityFetch = runtime.availability(domain: normalizedDomain)
 30        async let sslFetch = runtime.ssl(domain: normalizedDomain)
 31        async let hstsFetch = runtime.hsts(domain: normalizedDomain)
 32        async let httpFetch = runtime.http(domain: normalizedDomain)
 33        async let ownershipFetch = runtime.ownership(domain: normalizedDomain)
 34        async let redirectFetch = runtime.redirectChain(domain: normalizedDomain)
 35        async let subdomainFetch = runtime.subdomains(domain: normalizedDomain)
 36
 37        let resolvedDNS = await dnsFetch
 38        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=dns source=\(resolvedDNS.source.rawValue)")
 39        let dnsResult = normalizeErrors(in: resolvedDNS.value)
 40        track(
 41            .dns,
 42            source: resolvedDNS.source,
 43            provenance: provenance(for: .dns, source: resolvedDNS.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
 44            cachedSections: &cachedSections,
 45            sectionSources: &sectionSources,
 46            provenanceBySection: &provenanceBySection,
 47            dataSources: &dataSources
 48        )
 49        captureFailure(for: .dns, result: dnsResult, into: &errorDetails)
 50
 51        let availability = await availabilityFetch
 52        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=availability source=\(availability.source.rawValue)")
 53        track(
 54            .availability,
 55            source: availability.source,
 56            provenance: provenance(for: .availability, source: availability.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
 57            cachedSections: &cachedSections,
 58            sectionSources: &sectionSources,
 59            provenanceBySection: &provenanceBySection,
 60            dataSources: &dataSources
 61        )
 62
 63        let resolvedSSL = await sslFetch
 64        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=ssl source=\(resolvedSSL.source.rawValue)")
 65        let sslResult = normalizeErrors(in: resolvedSSL.value)
 66        track(
 67            .ssl,
 68            source: resolvedSSL.source,
 69            provenance: provenance(for: .ssl, source: resolvedSSL.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
 70            cachedSections: &cachedSections,
 71            sectionSources: &sectionSources,
 72            provenanceBySection: &provenanceBySection,
 73            dataSources: &dataSources
 74        )
 75        captureFailure(for: .ssl, result: sslResult, into: &errorDetails)
 76
 77        let hsts = await hstsFetch
 78        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=hsts source=\(hsts.source.rawValue)")
 79        track(
 80            .hsts,
 81            source: hsts.source,
 82            provenance: provenance(for: .hsts, source: hsts.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
 83            cachedSections: &cachedSections,
 84            sectionSources: &sectionSources,
 85            provenanceBySection: &provenanceBySection,
 86            dataSources: &dataSources
 87        )
 88
 89        let http = await httpFetch
 90        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=http source=\(http.source.rawValue)")
 91        let httpResult = normalizeErrors(in: http.value)
 92        track(
 93            .httpHeaders,
 94            source: http.source,
 95            provenance: provenance(for: .httpHeaders, source: http.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
 96            cachedSections: &cachedSections,
 97            sectionSources: &sectionSources,
 98            provenanceBySection: &provenanceBySection,
 99            dataSources: &dataSources
100        )
101        captureFailure(for: .httpHeaders, result: httpResult, into: &errorDetails)
102
103        let resolvedOwnership = await ownershipFetch
104        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=ownership source=\(resolvedOwnership.source.rawValue)")
105        let ownershipResult = normalizeErrors(in: resolvedOwnership.value)
106        track(
107            .ownership,
108            source: resolvedOwnership.source,
109            provenance: provenance(for: .ownership, source: resolvedOwnership.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
110            cachedSections: &cachedSections,
111            sectionSources: &sectionSources,
112            provenanceBySection: &provenanceBySection,
113            dataSources: &dataSources
114        )
115        captureFailure(for: .ownership, result: ownershipResult, into: &errorDetails)
116
117        let redirects = await redirectFetch
118        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=redirect source=\(redirects.source.rawValue)")
119        let redirectResult = normalizeErrors(in: redirects.value)
120        track(
121            .redirectChain,
122            source: redirects.source,
123            provenance: provenance(for: .redirectChain, source: redirects.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
124            cachedSections: &cachedSections,
125            sectionSources: &sectionSources,
126            provenanceBySection: &provenanceBySection,
127            dataSources: &dataSources
128        )
129        captureFailure(for: .redirectChain, result: redirectResult, into: &errorDetails)
130
131        let resolvedSubdomains = await subdomainFetch
132        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=subdomains source=\(resolvedSubdomains.source.rawValue)")
133        let subdomainResult = normalizeErrors(in: resolvedSubdomains.value)
134        track(
135            .subdomains,
136            source: resolvedSubdomains.source,
137            provenance: provenance(for: .subdomains, source: resolvedSubdomains.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
138            cachedSections: &cachedSections,
139            sectionSources: &sectionSources,
140            provenanceBySection: &provenanceBySection,
141            dataSources: &dataSources
142        )
143        captureFailure(for: .subdomains, result: subdomainResult, into: &errorDetails)
144
145        let dnsSections = mapServiceResult(dnsResult, emptyValue: [])
146        let sslInfo = mapOptionalValueServiceResult(sslResult)
147        let httpHeadersResult = mapHTTPResult(httpResult)
148        let redirectChain = mapServiceResult(redirectResult, emptyValue: [])
149        let ownership = mapOptionalValueServiceResult(ownershipResult)
150        let subdomains = mapServiceResult(subdomainResult, emptyValue: [])
151
152        let txtRecords = dnsSections.value.first(where: { $0.recordType == .TXT })?.records ?? []
153        let primaryIP = dnsSections.value.first(where: { $0.recordType == .A })?.records.first?.value
154        let hasNetworkTarget = dnsSections.value.contains { section in
155            (section.recordType == .A || section.recordType == .AAAA)
156                && (!section.records.isEmpty || !section.wildcardRecords.isEmpty)
157        }
158        let canReuseDNSDependents = canReuseDependentSections(from: previousSnapshot, dnsSections: dnsSections.value)
159        let canReuseIPDependents = canReuseIPBasedSections(from: previousSnapshot, primaryIP: primaryIP)
160
161        let reachabilityOutcome: CachedLookupResult<ServiceResult<[PortReachability]>>
162        if hasNetworkTarget {
163            reachabilityOutcome = await runtime.reachability(domain: normalizedDomain)
164        } else {
165            reachabilityOutcome = CachedLookupResult(value: .empty("No routable address available"), source: .live)
166        }
167        let reachabilityResult = normalizeErrors(in: reachabilityOutcome.value)
168        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=reachability hasNetworkTarget=\(hasNetworkTarget) source=\(reachabilityOutcome.source.rawValue)")
169        track(
170            .reachability,
171            source: reachabilityOutcome.source,
172            provenance: provenance(for: .reachability, source: reachabilityOutcome.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
173            cachedSections: &cachedSections,
174            sectionSources: &sectionSources,
175            provenanceBySection: &provenanceBySection,
176            dataSources: &dataSources
177        )
178        if hasNetworkTarget {
179            captureFailure(for: .reachability, result: reachabilityResult, into: &errorDetails)
180        }
181        let reachabilityResultValue = mapServiceResult(reachabilityResult, emptyValue: [])
182
183        let portScanOutcome: CachedLookupResult<ServiceResult<[PortScanResult]>>
184        if hasNetworkTarget {
185            portScanOutcome = await runtime.portScan(domain: normalizedDomain)
186        } else {
187            portScanOutcome = CachedLookupResult(value: .empty("No routable address available"), source: .live)
188        }
189        let portScanResult = normalizeErrors(in: portScanOutcome.value)
190        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=portScan hasNetworkTarget=\(hasNetworkTarget) source=\(portScanOutcome.source.rawValue)")
191        track(
192            .portScan,
193            source: portScanOutcome.source,
194            provenance: provenance(for: .portScan, source: portScanOutcome.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
195            cachedSections: &cachedSections,
196            sectionSources: &sectionSources,
197            provenanceBySection: &provenanceBySection,
198            dataSources: &dataSources
199        )
200        if hasNetworkTarget {
201            captureFailure(for: .portScan, result: portScanResult, into: &errorDetails)
202        }
203        let portScanResults = hasNetworkTarget
204            ? await mapPortScanResult(portScanResult, domain: normalizedDomain)
205            : (value: [], message: nil)
206
207        let emailOutcome: CachedLookupResult<ServiceResult<EmailSecurityResult>>
208        if canReuseDNSDependents, let previousSnapshot, let emailSecurity = previousSnapshot.emailSecurity {
209            emailOutcome = CachedLookupResult(value: .success(emailSecurity), source: .cached)
210        } else if canReuseDNSDependents, let previousSnapshot, let error = previousSnapshot.emailSecurityError {
211            emailOutcome = CachedLookupResult(value: .error(error), source: .cached)
212        } else {
213            emailOutcome = await runtime.email(domain: normalizedDomain, txtRecords: txtRecords)
214        }
215        let normalizedEmailResult = normalizeErrors(in: emailOutcome.value)
216        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=email source=\(emailOutcome.source.rawValue)")
217        track(
218            .emailSecurity,
219            source: emailOutcome.source,
220            provenance: provenance(for: .emailSecurity, source: emailOutcome.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
221            cachedSections: &cachedSections,
222            sectionSources: &sectionSources,
223            provenanceBySection: &provenanceBySection,
224            dataSources: &dataSources
225        )
226        captureFailure(for: .emailSecurity, result: normalizedEmailResult, into: &errorDetails)
227
228        let suggestionsOutcome: CachedLookupResult<[DomainSuggestionResult]>
229        if availability.value.status == .registered {
230            suggestionsOutcome = await runtime.suggestions(domain: normalizedDomain)
231            track(
232                .suggestions,
233                source: suggestionsOutcome.source,
234                provenance: provenance(for: .suggestions, source: suggestionsOutcome.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
235                cachedSections: &cachedSections,
236                sectionSources: &sectionSources,
237                provenanceBySection: &provenanceBySection,
238                dataSources: &dataSources
239            )
240        } else {
241            suggestionsOutcome = CachedLookupResult(value: [], source: .live)
242        }
243
244        let ptrOutcome: CachedLookupResult<ServiceResult<String>>?
245        let geoOutcome: CachedLookupResult<ServiceResult<IPGeolocation>>?
246        if let primaryIP {
247            if canReuseIPDependents, let previousSnapshot, let ptrRecord = previousSnapshot.ptrRecord {
248                ptrOutcome = CachedLookupResult(value: .success(ptrRecord), source: .cached)
249            } else if canReuseIPDependents, let previousSnapshot, let ptrError = previousSnapshot.ptrError {
250                ptrOutcome = CachedLookupResult(value: .error(ptrError), source: .cached)
251            } else {
252                ptrOutcome = await runtime.ptr(ip: primaryIP, resolverURLString: resolverURLString)
253            }
254
255            if canReuseIPDependents, let previousSnapshot, let ipGeolocation = previousSnapshot.ipGeolocation {
256                geoOutcome = CachedLookupResult(value: .success(ipGeolocation), source: .cached)
257            } else if canReuseIPDependents, let previousSnapshot, let ipGeolocationError = previousSnapshot.ipGeolocationError {
258                geoOutcome = CachedLookupResult(value: .error(ipGeolocationError), source: .cached)
259            } else {
260                geoOutcome = await runtime.ipGeolocation(ip: primaryIP)
261            }
262
263            if let ptrOutcome {
264                let normalizedPTRResult = normalizeErrors(in: ptrOutcome.value)
265                track(
266                    .ptr,
267                    source: ptrOutcome.source,
268                    provenance: provenance(for: .ptr, source: ptrOutcome.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
269                    cachedSections: &cachedSections,
270                    sectionSources: &sectionSources,
271                    provenanceBySection: &provenanceBySection,
272                    dataSources: &dataSources
273                )
274                captureFailure(for: .ptr, result: normalizedPTRResult, into: &errorDetails)
275            }
276            if let geoOutcome {
277                let normalizedGeoResult = normalizeErrors(in: geoOutcome.value)
278                track(
279                    .ipGeolocation,
280                    source: geoOutcome.source,
281                    provenance: provenance(for: .ipGeolocation, source: geoOutcome.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
282                    cachedSections: &cachedSections,
283                    sectionSources: &sectionSources,
284                    provenanceBySection: &provenanceBySection,
285                    dataSources: &dataSources
286                )
287                captureFailure(for: .ipGeolocation, result: normalizedGeoResult, into: &errorDetails)
288            }
289        } else {
290            ptrOutcome = nil
291            geoOutcome = nil
292        }
293
294        let emailSecurity = mapOptionalValueServiceResult(normalizedEmailResult)
295        let ptrRecord = mapOptionalServiceResult(ptrOutcome.map { normalizeErrors(in: $0.value) }, missingMessage: "No A record available")
296        let geolocation = mapOptionalServiceResult(geoOutcome.map { normalizeErrors(in: $0.value) }, missingMessage: "No A record available")
297        let availabilityConfidence = confidenceForAvailability(result: availability.value, provenance: provenanceBySection[.availability])
298        let ownershipConfidence = confidenceForOwnership(result: ownership.value)
299        let subdomainConfidence = confidenceForSubdomains(results: subdomains.value)
300        let emailConfidence = confidenceForEmail(result: emailSecurity.value)
301        let geolocationConfidence = confidenceForGeolocation(result: geolocation.value)
302        let validationIssues = validationIssues(for: normalizedDomain, snapshotTimestamp: startedAt, availability: availability.value, dnsSections: dnsSections.value, provenanceBySection: provenanceBySection)
303
304        let snapshot = LookupSnapshot(
305            historyEntryID: nil,
306            domain: availability.value.domain,
307            timestamp: Date(),
308            trackedDomainID: previousSnapshot?.trackedDomainID,
309            note: previousSnapshot?.note,
310            appVersion: AppVersion.current,
311            resolverDisplayName: resolverDisplayName,
312            resolverURLString: resolverURLString,
313            dataSources: Array(dataSources).sorted(),
314            provenanceBySection: provenanceBySection,
315            availabilityConfidence: availabilityConfidence,
316            ownershipConfidence: ownershipConfidence,
317            subdomainConfidence: subdomainConfidence,
318            emailSecurityConfidence: emailConfidence,
319            geolocationConfidence: geolocationConfidence,
320            errorDetails: errorDetails,
321            isPartialSnapshot: !validationIssues.isEmpty,
322            validationIssues: validationIssues,
323            totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000),
324            snapshotIndex: nil,
325            previousSnapshotID: previousSnapshot?.historyEntryID,
326            changeCount: 0,
327            severitySummary: nil,
328            dnsSections: dnsSections.value,
329            dnsError: dnsSections.message,
330            availabilityResult: availability.value,
331            suggestions: suggestionsOutcome.value,
332            sslInfo: sslInfo.value,
333            sslError: sslInfo.message,
334            hstsPreloaded: hsts.value,
335            httpHeaders: httpHeadersResult.headers,
336            httpSecurityGrade: httpHeadersResult.securityGrade,
337            httpStatusCode: httpHeadersResult.statusCode,
338            httpResponseTimeMs: httpHeadersResult.responseTimeMs,
339            httpProtocol: httpHeadersResult.httpProtocol,
340            http3Advertised: httpHeadersResult.http3Advertised,
341            httpHeadersError: httpHeadersResult.error,
342            reachabilityResults: reachabilityResultValue.value,
343            reachabilityError: reachabilityResultValue.message,
344            ipGeolocation: geolocation.value,
345            ipGeolocationError: geolocation.message,
346            emailSecurity: emailSecurity.value,
347            emailSecurityError: emailSecurity.message,
348            ownership: ownership.value,
349            ownershipError: ownership.message,
350            ownershipHistory: [],
351            ownershipHistoryError: nil,
352            inferredProvider: nil,
353            priorProviders: [],
354            domainClassification: nil,
355            ownershipTransitions: [],
356            hostingTransitions: [],
357            subdomainHistory: [],
358            riskSignals: [],
359            intelligenceTimeline: [],
360            ptrRecord: ptrRecord.value,
361            ptrError: ptrRecord.message,
362            redirectChain: redirectChain.value,
363            redirectChainError: redirectChain.message,
364            subdomains: subdomains.value,
365            subdomainsError: subdomains.message,
366            extendedSubdomains: [],
367            extendedSubdomainsError: nil,
368            dnsHistory: [],
369            dnsHistoryError: nil,
370            domainPricing: nil,
371            domainPricingError: nil,
372            reputation: nil,
373            reputationError: nil,
374            portScanResults: portScanResults.value,
375            portScanError: portScanResults.message,
376            changeSummary: nil,
377            resultSource: aggregateSource(sectionSources),
378            cachedSections: Array(cachedSections).sorted { $0.rawValue < $1.rawValue },
379            statusMessage: nil
380        )
381        DomainDebugLog.signpostEnd(
382            "Inspection.inspectSnapshot",
383            start: inspectionStartedAt,
384            domain: normalizedDomain,
385            extra: "resultSource=\(snapshot.resultSource.rawValue) cachedSections=\(snapshot.cachedSections.count) partial=\(snapshot.isPartialSnapshot)"
386        )
387        return snapshot
388    }
389
390    private func normalize(_ domain: String) -> String {
391        domain
392            .trimmingCharacters(in: .whitespacesAndNewlines)
393            .replacingOccurrences(of: "https://", with: "")
394            .replacingOccurrences(of: "http://", with: "")
395            .components(separatedBy: "/").first?
396            .lowercased() ?? domain.lowercased()
397    }
398
399    private func track(
400        _ section: LookupSectionKind,
401        source: LookupResultSource,
402        provenance: SectionProvenance,
403        cachedSections: inout Set<LookupSectionKind>,
404        sectionSources: inout [LookupResultSource],
405        provenanceBySection: inout [LookupSectionKind: SectionProvenance],
406        dataSources: inout Set<String>
407    ) {
408        sectionSources.append(source)
409        provenanceBySection[section] = provenance
410        dataSources.insert(provenance.provider ?? provenance.source)
411        if source != .live {
412            cachedSections.insert(section)
413        }
414    }
415
416    private func provenance(
417        for section: LookupSectionKind,
418        source: LookupResultSource,
419        collectedAt: Date,
420        resolverDisplayName: String
421    ) -> SectionProvenance {
422        switch section {
423        case .dns, .ptr:
424            return SectionProvenance(
425                source: "DNS-over-HTTPS query",
426                collectedAt: collectedAt,
427                provider: "Selected DoH resolver",
428                resolver: resolverDisplayName,
429                resultSource: source
430            )
431        case .availability:
432            return SectionProvenance(
433                source: "RDAP lookup with DNS fallback",
434                collectedAt: collectedAt,
435                provider: "rdap.org / selected resolver",
436                resolver: resolverDisplayName,
437                resultSource: source
438            )
439        case .ssl:
440            return SectionProvenance(source: "Direct TLS handshake", collectedAt: collectedAt, provider: "Target host", resolver: nil, resultSource: source)
441        case .hsts, .httpHeaders, .redirectChain:
442            return SectionProvenance(source: "HTTP request", collectedAt: collectedAt, provider: "Target host", resolver: nil, resultSource: source)
443        case .reachability:
444            return SectionProvenance(source: "TCP reachability probe", collectedAt: collectedAt, provider: "Target host", resolver: nil, resultSource: source)
445        case .ipGeolocation:
446            return SectionProvenance(source: "IP geolocation lookup", collectedAt: collectedAt, provider: "ipapi.co", resolver: nil, resultSource: source)
447        case .emailSecurity:
448            return SectionProvenance(source: "DNS TXT inspection", collectedAt: collectedAt, provider: "Selected DoH resolver", resolver: resolverDisplayName, resultSource: source)
449        case .ownership:
450            return SectionProvenance(source: "RDAP domain lookup", collectedAt: collectedAt, provider: "rdap.org", resolver: nil, resultSource: source)
451        case .subdomains:
452            return SectionProvenance(source: "Certificate transparency search", collectedAt: collectedAt, provider: "crt.sh", resolver: nil, resultSource: source)
453        case .portScan:
454            return SectionProvenance(source: "TCP port scan", collectedAt: collectedAt, provider: "Target host", resolver: nil, resultSource: source)
455        case .suggestions:
456            return SectionProvenance(source: "Availability suggestions", collectedAt: collectedAt, provider: "DomainDig heuristic", resolver: resolverDisplayName, resultSource: source)
457        }
458    }
459
460    private func aggregateSource(_ sectionSources: [LookupResultSource]) -> LookupResultSource {
461        let normalizedSources = sectionSources.map { source -> LookupResultSource in
462            source == .mixed ? .cached : source
463        }
464
465        let hasLive = normalizedSources.contains(.live)
466        let hasCached = normalizedSources.contains(.cached)
467
468        switch (hasLive, hasCached) {
469        case (true, true):
470            return .mixed
471        case (false, true):
472            return .cached
473        default:
474            return .live
475        }
476    }
477
478    private func canReuseDependentSections(from previousSnapshot: LookupSnapshot?, dnsSections: [DNSSection]) -> Bool {
479        guard let previousSnapshot else { return false }
480        return dnsSignature(for: previousSnapshot.dnsSections) == dnsSignature(for: dnsSections)
481    }
482
483    private func canReuseIPBasedSections(from previousSnapshot: LookupSnapshot?, primaryIP: String?) -> Bool {
484        guard let previousSnapshot else { return false }
485        let previousIP = previousSnapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
486        return primaryIP == previousIP
487    }
488
489    private func dnsSignature(for sections: [DNSSection]) -> String {
490        sections
491            .sorted { $0.recordType.rawValue < $1.recordType.rawValue }
492            .map { section in
493                let records = section.records
494                    .sorted { $0.value < $1.value }
495                    .map { "\($0.value)|\($0.ttl)" }
496                    .joined(separator: ",")
497                let wildcardRecords = section.wildcardRecords
498                    .sorted { $0.value < $1.value }
499                    .map { "\($0.value)|\($0.ttl)" }
500                    .joined(separator: ",")
501                return [
502                    section.recordType.rawValue,
503                    records,
504                    wildcardRecords,
505                    section.dnssecSigned.map { $0 ? "signed" : "unsigned" } ?? "unknown",
506                    section.error ?? ""
507                ].joined(separator: "#")
508            }
509            .joined(separator: "||")
510    }
511
512    private func normalizeErrors<Value>(in result: ServiceResult<Value>) -> ServiceResult<Value> {
513        switch result {
514        case let .success(value):
515            return .success(value)
516        case let .empty(message):
517            return .empty(classifyFailure(from: message, defaultKind: .unavailable).message)
518        case let .error(message):
519            return .error(classifyFailure(from: message).message)
520        }
521    }
522
523    private func classifyFailure(from message: String, defaultKind: InspectionErrorKind = .unknown) -> InspectionFailure {
524        let normalizedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines)
525        let lowercasedMessage = normalizedMessage.lowercased()
526        if lowercasedMessage.contains("timed out") {
527            return InspectionFailure(kind: .timeout, message: "Timed out", details: normalizedMessage)
528        }
529        if lowercasedMessage.contains("429")
530            || lowercasedMessage.contains("too many requests")
531            || lowercasedMessage.contains("rate limit") {
532            return InspectionFailure(kind: .rateLimited, message: "Rate limited", details: normalizedMessage)
533        }
534        if lowercasedMessage.contains("cannot parse")
535            || lowercasedMessage.contains("decoding")
536            || lowercasedMessage.contains("json") {
537            return InspectionFailure(kind: .parsing, message: "Could not parse response", details: normalizedMessage)
538        }
539        if lowercasedMessage.contains("offline")
540            || lowercasedMessage.contains("internet connection")
541            || lowercasedMessage.contains("not connected")
542            || lowercasedMessage.contains("network connection") {
543            return InspectionFailure(kind: .network, message: "Network unavailable", details: normalizedMessage)
544        }
545        if lowercasedMessage.contains("unsupported") {
546            return InspectionFailure(kind: .unsupported, message: "Unsupported for this target", details: normalizedMessage)
547        }
548        if lowercasedMessage == "unavailable" || lowercasedMessage.contains("no a record available") {
549            return InspectionFailure(kind: .unavailable, message: normalizedMessage, details: nil)
550        }
551        if defaultKind == .unavailable {
552            return InspectionFailure(kind: .unavailable, message: normalizedMessage, details: nil)
553        }
554        return InspectionFailure(kind: .unknown, message: normalizedMessage.isEmpty ? defaultKind.title : normalizedMessage, details: normalizedMessage)
555    }
556
557    private func captureFailure<Value>(
558        for section: LookupSectionKind,
559        result: ServiceResult<Value>,
560        into errorDetails: inout [LookupSectionKind: InspectionFailure]
561    ) {
562        switch result {
563        case .success:
564            return
565        case let .empty(message):
566            errorDetails[section] = classifyFailure(from: message, defaultKind: .unavailable)
567        case let .error(message):
568            errorDetails[section] = classifyFailure(from: message)
569        }
570    }
571
572    private func confidenceForAvailability(result: DomainAvailabilityResult, provenance: SectionProvenance?) -> ConfidenceLevel {
573        guard result.status != .unknown else { return .low }
574        if provenance?.provider?.localizedCaseInsensitiveContains("rdap.org") == true, result.status == .registered {
575            return .high
576        }
577        if result.status == .registered {
578            return .medium
579        }
580        return .low
581    }
582
583    private func confidenceForOwnership(result: DomainOwnership?) -> ConfidenceLevel {
584        guard let result else { return .low }
585        let hasDirectRegistrationData = result.registrar != nil || result.createdDate != nil || result.expirationDate != nil
586        return hasDirectRegistrationData ? .high : .medium
587    }
588
589    private func confidenceForSubdomains(results: [DiscoveredSubdomain]) -> ConfidenceLevel {
590        if !results.isEmpty {
591            return .medium
592        }
593        return .low
594    }
595
596    private func confidenceForEmail(result: EmailSecurityResult?) -> ConfidenceLevel {
597        guard let result else { return .low }
598        let foundCount = [result.spf.found, result.dmarc.found, result.dkim.found, result.bimi.found, result.mtaSts?.txtFound == true]
599            .filter { $0 }
600            .count
601        if foundCount >= 3 {
602            return .high
603        }
604        if foundCount >= 1 {
605            return .medium
606        }
607        return .low
608    }
609
610    private func confidenceForGeolocation(result: IPGeolocation?) -> ConfidenceLevel {
611        guard let result else { return .low }
612        if result.city != nil && result.countryName != nil && result.latitude != nil && result.longitude != nil {
613            return .high
614        }
615        if result.countryName != nil || result.org != nil {
616            return .medium
617        }
618        return .low
619    }
620
621    private func validationIssues(
622        for domain: String,
623        snapshotTimestamp: Date,
624        availability: DomainAvailabilityResult,
625        dnsSections: [DNSSection],
626        provenanceBySection: [LookupSectionKind: SectionProvenance]
627    ) -> [String] {
628        var issues: [String] = []
629        if domain.isEmpty {
630            issues.append("Missing normalized domain")
631        }
632        if availability.domain.isEmpty {
633            issues.append("Missing normalized availability domain")
634        }
635        if dnsSections.isEmpty && provenanceBySection[.dns] == nil {
636            issues.append("Missing DNS provenance")
637        }
638        if snapshotTimestamp > Date().addingTimeInterval(5) {
639            issues.append("Snapshot timestamp is in the future")
640        }
641        return issues
642    }
643
644    private func mapServiceResult<Value>(_ result: ServiceResult<Value>, emptyValue: Value) -> (value: Value, message: String?) {
645        switch result {
646        case let .success(value):
647            return (value, nil)
648        case let .empty(message), let .error(message):
649            return (emptyValue, message)
650        }
651    }
652
653    private func mapOptionalValueServiceResult<Value>(_ result: ServiceResult<Value>) -> (value: Value?, message: String?) {
654        switch result {
655        case let .success(value):
656            return (value, nil)
657        case let .empty(message), let .error(message):
658            return (nil, message)
659        }
660    }
661
662    private func mapOptionalServiceResult<Value>(
663        _ result: ServiceResult<Value>?,
664        missingMessage: String
665    ) -> (value: Value?, message: String?) {
666        guard let result else {
667            return (nil, missingMessage)
668        }
669
670        switch result {
671        case let .success(value):
672            return (value, nil)
673        case let .empty(message), let .error(message):
674            return (nil, message)
675        }
676    }
677
678    private func mapPortScanResult(_ result: ServiceResult<[PortScanResult]>, domain: String) async -> (value: [PortScanResult], message: String?) {
679        switch result {
680        case let .success(results):
681            return (await enrichOpenPortBanners(in: results, domain: domain), nil)
682        case let .empty(message), let .error(message):
683            return ([], message)
684        }
685    }
686
687    private func mapHTTPResult(_ result: ServiceResult<HTTPHeadersResult>) -> (
688        headers: [HTTPHeader],
689        securityGrade: String?,
690        statusCode: Int?,
691        responseTimeMs: Int?,
692        httpProtocol: String?,
693        http3Advertised: Bool,
694        error: String?
695    ) {
696        switch result {
697        case let .success(value):
698            return (
699                headers: value.headers,
700                securityGrade: HTTPSecurityGrade.grade(for: value.headers).rawValue,
701                statusCode: value.statusCode,
702                responseTimeMs: value.responseTimeMs,
703                httpProtocol: value.httpProtocol,
704                http3Advertised: value.http3Advertised,
705                error: nil
706            )
707        case let .empty(message), let .error(message):
708            return (
709                headers: [],
710                securityGrade: nil,
711                statusCode: nil,
712                responseTimeMs: nil,
713                httpProtocol: nil,
714                http3Advertised: false,
715                error: message
716            )
717        }
718    }
719
720    private func enrichOpenPortBanners(in results: [PortScanResult], domain: String) async -> [PortScanResult] {
721        let banners = await withTaskGroup(of: (UInt16, String?).self, returning: [UInt16: String].self) { group in
722            for result in results where result.open {
723                group.addTask {
724                    let banner = await PortScanService.grabBanner(host: domain, port: result.port)
725                    return (result.port, banner)
726                }
727            }
728
729            var collected: [UInt16: String] = [:]
730            for await (port, banner) in group {
731                if let banner {
732                    collected[port] = banner
733                }
734            }
735            return collected
736        }
737
738        return results.map { result in
739            var updated = result
740            updated.banner = banners[result.port]
741            return updated
742        }
743    }
744}