krz/domain-dig

an ios app for DNS & SSL analysis

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

v4.7.0: DomainReportExporter.swift · raw

  1import Foundation
  2
  3enum DomainExportFormat: String {
  4    case text = "txt"
  5    case csv = "csv"
  6    case json = "json"
  7
  8    var fileExtension: String { rawValue }
  9}
 10
 11enum DomainReportExporter {
 12    static func data(for report: DomainReport, format: DomainExportFormat) throws -> Data {
 13        switch format {
 14        case .text:
 15            return Data(text(for: report).utf8)
 16        case .csv:
 17            return Data(csv(for: [report]).utf8)
 18        case .json:
 19            return try jsonEncoder.encode(report)
 20        }
 21    }
 22
 23    static func data(for reports: [DomainReport], format: DomainExportFormat, title: String) throws -> Data {
 24        switch format {
 25        case .text:
 26            return Data(batchText(for: reports, title: title).utf8)
 27        case .csv:
 28            return Data(csv(for: reports).utf8)
 29        case .json:
 30            return try jsonEncoder.encode(reports)
 31        }
 32    }
 33
 34    static func text(for report: DomainReport) -> String {
 35        var lines = [
 36            "DomainDig Report",
 37            "Domain: \(report.domain)",
 38            "Timestamp: \(textDateFormatter.string(from: report.timestamp))",
 39            "App Version: \(report.metadata.appVersion)",
 40            "Resolver: \(report.metadata.resolverDisplayName)",
 41            "Resolver URL: \(report.metadata.resolverURLString)",
 42            "Source: \(report.provenance.source.label)",
 43            "Availability: \(availabilityLabel(report.availability))",
 44            "Availability Confidence: \(report.availabilityConfidence?.title ?? "N/A")"
 45        ]
 46
 47        if report.metadata.isPartialSnapshot {
 48            lines.append("Snapshot Integrity: Partial snapshot")
 49        }
 50        if let auditNote = report.metadata.auditNote, !auditNote.isEmpty {
 51            lines.append("Audit Note: \(auditNote)")
 52        }
 53        if !report.provenance.dataSources.isEmpty {
 54            lines.append("Data Sources: \(report.provenance.dataSources.joined(separator: ", "))")
 55        }
 56        if !report.metadata.validationIssues.isEmpty {
 57            lines.append("Validation: \(report.metadata.validationIssues.joined(separator: " | "))")
 58        }
 59        if let workflowContext = report.workflowContext {
 60            lines.append("Workflow Context: \(workflowContext.workflowName ?? workflowContext.source)")
 61        }
 62
 63        appendSection("Summary", to: &lines) {
 64            [
 65                "Primary IP: \(report.dns.primaryIP ?? "Unavailable")",
 66                "Risk Score: \(report.riskAssessment.score) (\(report.riskAssessment.level.title))",
 67                "TLS Status: \(report.web.tlsStatus)",
 68                "HTTP: \(httpSummary(for: report))",
 69                "Email: \(report.email.summary)",
 70                "Subdomains: \(report.subdomains.count)",
 71                "Extended Subdomains: \(report.extendedSubdomains.count)",
 72                "External Price: \(report.domainPricing?.estimatedPrice ?? "Unavailable")",
 73                "Reputation: \(report.reputation?.status.title ?? "Unavailable")"
 74            ]
 75        }
 76
 77        appendSection("Risk", to: &lines) {
 78            var values = [
 79                "Score: \(report.riskAssessment.score)",
 80                "Level: \(report.riskAssessment.level.title)"
 81            ]
 82            if report.riskAssessment.factors.isEmpty {
 83                values.append("Factors: None")
 84            } else {
 85                values.append("Factors:")
 86                for factor in report.riskAssessment.factors {
 87                    values.append("  [\(factor.impact.rawValue)] \(factor.description)")
 88                }
 89            }
 90            return values
 91        }
 92
 93        appendSection("Insights", to: &lines) {
 94            report.insights.isEmpty ? ["No deterministic insights triggered"] : report.insights.map { "- \($0)" }
 95        }
 96
 97        appendSection("Ownership", to: &lines) {
 98            var ownershipLines = [
 99                "Registrar: \(report.ownership?.registrar ?? "Unavailable")",
100                "Confidence: \(report.ownershipConfidence?.title ?? "N/A")",
101                "Created: \(ownershipDateLabel(report.ownership?.createdDate))",
102                "Expires: \(ownershipDateLabel(report.ownership?.expirationDate))",
103                "Registrant: \(report.ownership?.registrant ?? "Unavailable")",
104                "Nameservers: \(joined(report.ownership?.nameservers) ?? "Unavailable")",
105                "Status: \(joined(report.ownership?.status) ?? "Unavailable")",
106                "Abuse Contact: \(report.ownership?.abuseEmail ?? "Unavailable")"
107            ]
108            if let provenance = report.sectionProvenance[.ownership] {
109                ownershipLines.append("Provenance: \(provenanceLabel(provenance))")
110            }
111            if let error = report.dns.error, report.ownership == nil {
112                ownershipLines.append("Error: \(error)")
113            } else if let error = report.changeSummary?.message, report.ownership == nil, report.ownership == nil {
114                _ = error
115            }
116            return ownershipLines
117        }
118
119        appendSection("Ownership History", to: &lines) {
120            guard !report.ownershipHistory.isEmpty else {
121                return ["No ownership history available"]
122            }
123
124            return report.ownershipHistory.map { event in
125                [
126                    textDateFormatter.string(from: event.date),
127                    event.summary,
128                    "source=\(event.source)"
129                ].joined(separator: " | ")
130            }
131        }
132
133        appendSection("DNS", to: &lines) {
134            var dnsLines = [
135                "Lookup Duration: \(durationLabel(report.dns.lookupDurationMs))",
136                "Primary IP: \(report.dns.primaryIP ?? "Unavailable")",
137                "PTR: \(report.dns.ptrRecord ?? report.dns.ptrError ?? "Unavailable")",
138                "DNSSEC: \(dnssecLabel(report.dns.dnssecSigned))",
139                "Patterns: \(report.dns.patternSummary.patterns.joined(separator: " | ").nilIfEmpty ?? "None")"
140            ]
141            if let provenance = report.sectionProvenance[.dns] {
142                dnsLines.append("Provenance: \(provenanceLabel(provenance))")
143            }
144            if let error = report.dns.error {
145                dnsLines.append("Error: \(error)")
146            }
147            if report.dns.recordSections.isEmpty {
148                dnsLines.append("Records: None")
149            } else {
150                dnsLines.append("Records:")
151                for section in report.dns.recordSections {
152                    let values = (section.records + section.wildcardRecords).map(\.value)
153                    let renderedValues = values.isEmpty ? "None" : values.joined(separator: " | ")
154                    dnsLines.append("  \(section.recordType.rawValue): \(renderedValues)")
155                }
156            }
157            return dnsLines
158        }
159
160        appendSection("DNS History", to: &lines) {
161            guard !report.dnsHistory.isEmpty else {
162                return ["No DNS history available"]
163            }
164
165            return report.dnsHistory.map { event in
166                [
167                    textDateFormatter.string(from: event.date),
168                    event.summary,
169                    "A=\(event.aRecords.joined(separator: " | ").nilIfEmpty ?? "-")",
170                    "NS=\(event.nameservers.joined(separator: " | ").nilIfEmpty ?? "-")",
171                    "source=\(event.source)"
172                ].joined(separator: " | ")
173            }
174        }
175
176        appendSection("Web", to: &lines) {
177            var webLines = [
178                "TLS Status: \(report.web.tlsStatus)",
179                "TLS Grade: \(report.web.tlsGrade.rawValue)",
180                "TLS Highlights: \(report.web.tlsHighlights.joined(separator: " | "))",
181                "Certificate Warning: \(report.web.certificateWarningLevel.title)",
182                "Security Grade: \(report.web.securityGrade ?? "Unavailable")",
183                "HTTP Status: \(report.web.statusCode.map(String.init) ?? "Unavailable")",
184                "Protocol: \(report.web.httpProtocol ?? "Unavailable")",
185                "HTTP/3 Advertised: \(report.web.http3Advertised ? "Yes" : "No")",
186                "Final URL: \(report.web.finalURL ?? "Unavailable")",
187                "HSTS Preloaded: \(booleanLabel(report.web.hstsPreloaded))",
188                "Header Count: \(report.web.headerCount)"
189            ]
190            if let provenance = report.sectionProvenance[.ssl] {
191                webLines.append("TLS Provenance: \(provenanceLabel(provenance))")
192            }
193            if let provenance = report.sectionProvenance[.httpHeaders] {
194                webLines.append("HTTP Provenance: \(provenanceLabel(provenance))")
195            }
196            if let provenance = report.sectionProvenance[.redirectChain] {
197                webLines.append("Redirect Provenance: \(provenanceLabel(provenance))")
198            }
199            if let tlsError = report.web.tlsError {
200                webLines.append("TLS Error: \(tlsError)")
201            }
202            if let headersError = report.web.headersError {
203                webLines.append("Headers Error: \(headersError)")
204            }
205            if let redirectError = report.web.redirectError {
206                webLines.append("Redirect Error: \(redirectError)")
207            }
208            if !report.web.headers.isEmpty {
209                webLines.append("Headers:")
210                for header in report.web.headers {
211                    webLines.append("  \(header.name): \(header.value)")
212                }
213            }
214            if !report.web.redirectChain.isEmpty {
215                webLines.append("Redirect Chain:")
216                for hop in report.web.redirectChain {
217                    webLines.append("  \(hop.stepNumber). \(hop.statusCode) \(hop.url)\(hop.isFinal ? " (final)" : "")")
218                }
219            }
220            return webLines
221        }
222
223        appendSection("Email", to: &lines) {
224            var emailLines = [report.email.summary]
225            if let grade = report.email.grade {
226                emailLines.append("Grade: \(grade.rawValue)")
227            }
228            if !report.email.reasons.isEmpty {
229                emailLines.append("Why: \(report.email.reasons.joined(separator: " | "))")
230            }
231            emailLines.append("Confidence: \(report.emailConfidence?.title ?? "N/A")")
232            if let provenance = report.sectionProvenance[.emailSecurity] {
233                emailLines.append("Provenance: \(provenanceLabel(provenance))")
234            }
235            if let records = report.email.records {
236                emailLines.append("SPF: \(recordLabel(records.spf))")
237                emailLines.append("DMARC: \(recordLabel(records.dmarc))")
238                emailLines.append("DKIM: \(recordLabel(records.dkim))")
239                emailLines.append("BIMI: \(recordLabel(records.bimi))")
240                emailLines.append("MTA-STS: \(records.mtaSts?.txtFound == true ? records.mtaSts?.policyMode ?? "found" : "Unavailable")")
241            }
242            if let error = report.email.error {
243                emailLines.append("Error: \(error)")
244            }
245            return emailLines
246        }
247
248        appendSection("Network", to: &lines) {
249            var networkLines = [
250                "Reachability: \(report.network.reachabilitySummary)",
251                "Geolocation: \(report.network.geolocationSummary)",
252                "Geolocation Confidence: \(report.geolocationConfidence?.title ?? "N/A")",
253                "Open Ports: \(report.network.openPorts.map(String.init).joined(separator: ", ").nilIfEmpty ?? "None")"
254            ]
255            if let provenance = report.sectionProvenance[.ipGeolocation] {
256                networkLines.append("Geolocation Provenance: \(provenanceLabel(provenance))")
257            }
258            if let error = report.network.reachabilityError {
259                networkLines.append("Reachability Error: \(error)")
260            }
261            if let error = report.network.geolocationError {
262                networkLines.append("Geolocation Error: \(error)")
263            }
264            if let error = report.network.portScanError {
265                networkLines.append("Port Scan Error: \(error)")
266            }
267            if !report.network.portScan.isEmpty {
268                networkLines.append("Port Scan:")
269                for result in report.network.portScan {
270                    networkLines.append(
271                        "  \(result.port) \(result.service): \(result.open ? "open" : "closed")\(result.banner.map { " banner=\($0)" } ?? "")"
272                    )
273                }
274            }
275            return networkLines
276        }
277
278        appendSection("Subdomains", to: &lines) {
279            var values = ["Confidence: \(report.subdomainConfidence?.title ?? "N/A")"]
280            if let provenance = report.sectionProvenance[.subdomains] {
281                values.append("Provenance: \(provenanceLabel(provenance))")
282            }
283            if report.subdomains.isEmpty {
284                values.append("None")
285                return values
286            }
287            if !report.subdomainGroups.isEmpty {
288                values.append("Groups: \(report.subdomainGroups.map { "\($0.label): \($0.subdomains.count)" }.joined(separator: " | "))")
289            }
290            values.append(contentsOf: report.subdomains.map { "- \($0)" })
291            if !report.extendedSubdomains.isEmpty {
292                values.append("Extended:")
293                values.append(contentsOf: report.extendedSubdomains.map { "- \($0)" })
294            }
295            return values
296        }
297
298        appendSection("Pricing", to: &lines) {
299            guard let pricing = report.domainPricing else {
300                return ["External pricing unavailable"]
301            }
302
303            return [
304                "Estimated Price: \(pricing.estimatedPrice ?? "Unavailable")",
305                "Premium: \(pricing.premiumIndicator == true ? "Yes" : "No")",
306                "Resale: \(pricing.resaleSignal ?? "Unavailable")",
307                "Auction: \(pricing.auctionSignal ?? "Unavailable")",
308                "Source: \(pricing.source)",
309                "Collected: \(textDateFormatter.string(from: pricing.collectedAt))"
310            ]
311        }
312
313        appendSection("Changes", to: &lines) {
314            guard let changeSummary = report.changeSummary else {
315                return ["No comparison available"]
316            }
317
318            var values = [
319                "Has Changes: \(changeSummary.hasChanges ? "Yes" : "No")",
320                "Severity: \(changeSummary.severity.title)",
321                "Impact: \(changeSummary.impactClassification.title)",
322                "Inferred Summary: \(changeSummary.message)",
323                "Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))"
324            ]
325            if let riskScoreDelta = changeSummary.riskScoreDelta {
326                values.append("Risk Delta: \(riskScoreDelta >= 0 ? "+" : "")\(riskScoreDelta)")
327            }
328            if !changeSummary.insights.isEmpty {
329                values.append("Insights: \(changeSummary.insights.joined(separator: " | "))")
330            }
331            if !changeSummary.observedFacts.isEmpty {
332                values.append("Observed: \(changeSummary.observedFacts.joined(separator: " | "))")
333            }
334            if let contextNote = changeSummary.contextNote {
335                values.append("Context: \(contextNote)")
336            }
337            return values
338        }
339
340        return lines.joined(separator: "\n")
341    }
342
343    static func batchText(for reports: [DomainReport], title: String) -> String {
344        guard !reports.isEmpty else {
345            return "\(title)\nNo results available."
346        }
347
348        var lines = [title, String(repeating: "=", count: title.count), ""]
349        for (index, report) in reports.enumerated() {
350            if index > 0 {
351                lines.append("")
352                lines.append(String(repeating: "=", count: 48))
353                lines.append("")
354            }
355            lines.append(text(for: report))
356        }
357        return lines.joined(separator: "\n")
358    }
359
360    static func csv(for reports: [DomainReport]) -> String {
361        csv(for: reports, workflowInsights: [])
362    }
363
364    static func csv(for reports: [DomainReport], workflowInsights: [WorkflowInsight]) -> String {
365        let workflowInsightSummary = workflowInsights.map(\.description).joined(separator: " | ")
366        let headers = [
367            "domain",
368            "timestamp",
369            "app_version",
370            "result_source",
371            "resolver",
372            "availability",
373            "risk_score",
374            "risk_level",
375            "risk_factors",
376            "insights",
377            "availability_confidence",
378            "registrar",
379            "ownership_confidence",
380            "ownership_expires",
381            "nameservers",
382            "primary_ip",
383            "ptr_record",
384            "dnssec_signed",
385            "dns_patterns",
386            "tls_status",
387            "tls_grade",
388            "tls_highlights",
389            "certificate_warning_level",
390            "hsts_preloaded",
391            "http_status",
392            "http_security_grade",
393            "final_url",
394            "email_summary",
395            "email_grade",
396            "email_confidence",
397            "subdomain_count",
398            "extended_subdomain_count",
399            "subdomain_groups",
400            "subdomain_confidence",
401            "subdomains",
402            "extended_subdomains",
403            "ownership_history",
404            "dns_history",
405            "pricing_estimated",
406            "pricing_premium",
407            "pricing_resale_signal",
408            "pricing_auction_signal",
409            "pricing_source",
410            "reputation_status",
411            "reputation_listed_sources",
412            "open_ports",
413            "reachability_summary",
414            "geolocation_summary",
415            "geolocation_confidence",
416            "data_sources",
417            "audit_note",
418            "partial_snapshot",
419            "workflow_name",
420            "workflow_source",
421            "cached_sections",
422            "change_summary",
423            "change_impact",
424            "workflow_insights"
425        ]
426
427        let rows = reports.map { report in
428            let expirationDate = report.ownership?.expirationDate.map(csvDateFormatter.string(from:)) ?? ""
429            let nameservers = joined(report.ownership?.nameservers) ?? ""
430            let dnssecSigned = report.dns.dnssecSigned.map { $0 ? "true" : "false" } ?? ""
431            let hstsPreloaded = report.web.hstsPreloaded.map { $0 ? "true" : "false" } ?? ""
432            let httpStatus = report.web.statusCode.map(String.init) ?? ""
433            let subdomainCount = String(report.subdomains.count)
434            let subdomains = report.subdomains.joined(separator: " | ")
435            let extendedSubdomains = report.extendedSubdomains.joined(separator: " | ")
436            let openPorts = report.network.openPorts.map(String.init).joined(separator: " | ")
437            let riskFactors = report.riskAssessment.factors.map(\.description).joined(separator: " | ")
438            let insights = report.insights.joined(separator: " | ")
439            let dnsPatterns = report.dns.patternSummary.patterns.joined(separator: " | ")
440            let tlsHighlights = report.web.tlsHighlights.joined(separator: " | ")
441            let subdomainGroups = report.subdomainGroups.map { "\($0.label):\($0.subdomains.count)" }.joined(separator: " | ")
442            let ownershipHistory = report.ownershipHistory.map { "\($0.date.ISO8601Format()) \($0.summary)" }.joined(separator: " | ")
443            let dnsHistory = report.dnsHistory.map { "\($0.date.ISO8601Format()) \($0.summary)" }.joined(separator: " | ")
444            let reputationStatus = report.reputation?.status.rawValue ?? ""
445            let reputationListedSources = report.reputation?.listedSources.joined(separator: " | ") ?? ""
446
447            return [
448                report.domain,
449                csvDateFormatter.string(from: report.timestamp),
450                report.appVersion,
451                report.resultSource.rawValue,
452                report.resolverDisplayName,
453                availabilityLabel(report.availability),
454                "\(report.riskAssessment.score)",
455                report.riskAssessment.level.rawValue,
456                riskFactors,
457                insights,
458                report.availabilityConfidence?.rawValue ?? "",
459                report.ownership?.registrar ?? "",
460                report.ownershipConfidence?.rawValue ?? "",
461                expirationDate,
462                nameservers,
463                report.dns.primaryIP ?? "",
464                report.dns.ptrRecord ?? "",
465                dnssecSigned,
466                dnsPatterns,
467                report.web.tlsStatus,
468                report.web.tlsGrade.rawValue,
469                tlsHighlights,
470                report.web.certificateWarningLevel.rawValue,
471                hstsPreloaded,
472                httpStatus,
473                report.web.securityGrade ?? "",
474                report.web.finalURL ?? "",
475                report.email.summary,
476                report.email.grade?.rawValue ?? "",
477                report.emailConfidence?.rawValue ?? "",
478                subdomainCount,
479                String(report.extendedSubdomains.count),
480                subdomainGroups,
481                report.subdomainConfidence?.rawValue ?? "",
482                subdomains,
483                extendedSubdomains,
484                ownershipHistory,
485                dnsHistory,
486                report.domainPricing?.estimatedPrice ?? "",
487                report.domainPricing?.premiumIndicator == true ? "true" : "false",
488                report.domainPricing?.resaleSignal ?? "",
489                report.domainPricing?.auctionSignal ?? "",
490                report.domainPricing?.source ?? "",
491                reputationStatus,
492                reputationListedSources,
493                openPorts,
494                report.network.reachabilitySummary,
495                report.network.geolocationSummary,
496                report.geolocationConfidence?.rawValue ?? "",
497                report.provenance.dataSources.joined(separator: " | "),
498                report.metadata.auditNote ?? "",
499                report.metadata.isPartialSnapshot ? "true" : "false",
500                report.workflowContext?.workflowName ?? "",
501                report.workflowContext?.source ?? "",
502                report.metadata.cachedSections.map(\.rawValue).joined(separator: " | "),
503                report.changeSummary?.message ?? "",
504                report.changeSummary?.impactClassification.rawValue ?? "",
505                workflowInsightSummary
506            ]
507        }
508
509        return ([headers] + rows)
510            .map { row in row.map(csvEscaped).joined(separator: ",") }
511            .joined(separator: "\n")
512    }
513
514    static func timelineText(for reports: [DomainReport], domain: String, includeDiffSummary: Bool) -> String {
515        guard !reports.isEmpty else {
516            return "Timeline Export\nNo snapshots available for \(domain)."
517        }
518
519        var lines = [
520            "DomainDig Timeline Export",
521            "Domain: \(domain)",
522            "Snapshots: \(reports.count)"
523        ]
524
525        for report in reports.sorted(by: { $0.timestamp > $1.timestamp }) {
526            lines.append("")
527            lines.append("\(textDateFormatter.string(from: report.timestamp))")
528            lines.append("Summary: \(report.changeSummary?.message ?? "No change summary")")
529            lines.append("Severity: \(report.changeSummary?.severity.title ?? "N/A")")
530            if includeDiffSummary, let changeSummary = report.changeSummary {
531                lines.append("Changed Sections: \(changeSummary.changedSections.joined(separator: ", ").nilIfEmpty ?? "None")")
532            }
533        }
534
535        return lines.joined(separator: "\n")
536    }
537
538    static func timelineData(for reports: [DomainReport], domain: String, includeDiffSummary: Bool) throws -> Data {
539        struct TimelineExportEntry: Codable {
540            let timestamp: Date
541            let summary: String?
542            let severity: String?
543            let changedSections: [String]?
544        }
545
546        struct TimelineExportPayload: Codable {
547            let domain: String
548            let exportedAt: Date
549            let snapshots: [TimelineExportEntry]
550        }
551
552        let payload = TimelineExportPayload(
553            domain: domain,
554            exportedAt: Date(),
555            snapshots: reports
556                .sorted(by: { $0.timestamp > $1.timestamp })
557                .map { report in
558                    TimelineExportEntry(
559                        timestamp: report.timestamp,
560                        summary: report.changeSummary?.message,
561                        severity: report.changeSummary?.severity.title,
562                        changedSections: includeDiffSummary ? report.changeSummary?.changedSections : nil
563                    )
564                }
565        )
566
567        return try jsonEncoder.encode(payload)
568    }
569
570    private static func appendSection(_ title: String, to lines: inout [String], body: () -> [String]) {
571        lines.append("")
572        lines.append(title)
573        lines.append(String(repeating: "-", count: title.count))
574        lines.append(contentsOf: body())
575    }
576
577    private static func availabilityLabel(_ status: DomainAvailabilityStatus) -> String {
578        switch status {
579        case .available:
580            return "Available"
581        case .registered:
582            return "Registered"
583        case .unknown:
584            return "Unknown"
585        }
586    }
587
588    private static func joined(_ values: [String]?) -> String? {
589        guard let values, !values.isEmpty else { return nil }
590        return values.joined(separator: " | ")
591    }
592
593    private static func dnssecLabel(_ value: Bool?) -> String {
594        switch value {
595        case true:
596            return "Signed"
597        case false:
598            return "Unsigned"
599        case nil:
600            return "Unavailable"
601        }
602    }
603
604    private static func ownershipDateLabel(_ date: Date?) -> String {
605        guard let date else { return "Unavailable" }
606        return textDateFormatter.string(from: date)
607    }
608
609    private static func durationLabel(_ durationMs: Int?) -> String {
610        durationMs.map { "\($0) ms" } ?? "Unavailable"
611    }
612
613    private static func httpSummary(for report: DomainReport) -> String {
614        let parts = [report.web.statusCode.map(String.init), report.web.securityGrade].compactMap { $0 }
615        return parts.isEmpty ? report.web.headersError ?? "Unavailable" : parts.joined(separator: " / ")
616    }
617
618    private static func booleanLabel(_ value: Bool?) -> String {
619        guard let value else { return "Unavailable" }
620        return value ? "Yes" : "No"
621    }
622
623    private static func recordLabel(_ record: EmailSecurityRecord) -> String {
624        if record.found {
625            return record.value ?? "Present"
626        }
627        return "Unavailable"
628    }
629
630    private static func provenanceLabel(_ provenance: SectionProvenance) -> String {
631        [
632            provenance.source,
633            provenance.provider,
634            provenance.resolver.map { "resolver=\($0)" },
635            provenance.resultSource.label.lowercased(),
636            textDateFormatter.string(from: provenance.collectedAt)
637        ]
638        .compactMap { $0 }
639        .joined(separator: " | ")
640    }
641
642    private static let textDateFormatter: DateFormatter = {
643        let formatter = DateFormatter()
644        formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
645        return formatter
646    }()
647
648    private static let csvDateFormatter: ISO8601DateFormatter = {
649        let formatter = ISO8601DateFormatter()
650        formatter.formatOptions = [.withInternetDateTime]
651        return formatter
652    }()
653
654    private static let jsonEncoder: JSONEncoder = {
655        let encoder = JSONEncoder()
656        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
657        encoder.dateEncodingStrategy = .iso8601
658        return encoder
659    }()
660
661    nonisolated private static func csvEscaped(_ value: String) -> String {
662        let escaped = value.replacingOccurrences(of: "\"", with: "\"\"")
663        return "\"\(escaped)\""
664    }
665}
666
667private extension String {
668    var nilIfEmpty: String? {
669        isEmpty ? nil : self
670    }
671}