krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
1bc0c44013e577a8fe61bcef5fd1590c0692a0a4
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-22T06:33:28Z
.gitignore | 3 + DomainDig.xcodeproj/project.pbxproj | 4 + DomainDig/DNSLookupService.swift | 2 +- DomainDig/DomainMonitoringService.swift | 134 ++++++------ DomainDig/DomainViewModel.swift | 158 +++++++------- DomainDig/Models.swift | 297 ++++++++++++++++---------- DomainDigTests/HistoryEntryCodableTests.swift | 129 +++++++++++ 7 files changed, 487 insertions(+), 240 deletions(-) new file mode 100644 @@ -0,0 +1,3 @@ + +# Xcode per-user state +xcuserdata/ @@ -12,6 +12,7 @@ 47CD3BB1AE143733A73E0E5B /* DomainReportExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D7C7D97A4006831572F468 /* DomainReportExporterTests.swift */; }; 4F96CEB875EC3501E784CE39 /* DataMigrationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435AFB3F99D579D3D7B58279 /* DataMigrationServiceTests.swift */; }; 81359F63C7A23454B8FA0141 /* DomainReportBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E82320955416797CFE59464A /* DomainReportBuilderTests.swift */; }; + A1B2C3D40000000000000102 /* HistoryEntryCodableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000000000101 /* HistoryEntryCodableTests.swift */; }; 8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */; }; 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; }; 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; }; @@ -98,6 +99,7 @@ CC948A02EC0184228BC4630E /* DomainDataPortabilityServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DomainDataPortabilityServiceTests.swift; sourceTree = "<group>"; }; DE8B269A01CC5E593DA3DFC2 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; E82320955416797CFE59464A /* DomainReportBuilderTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DomainReportBuilderTests.swift; sourceTree = "<group>"; }; + A1B2C3D40000000000000101 /* HistoryEntryCodableTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HistoryEntryCodableTests.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -235,6 +237,7 @@ 0D85A44C5F315A1644AC9073 /* LocalAPIContractTests.swift */, 435AFB3F99D579D3D7B58279 /* DataMigrationServiceTests.swift */, 499B99A1866999A38AC475F5 /* AppInfoTests.swift */, + A1B2C3D40000000000000101 /* HistoryEntryCodableTests.swift */, ); name = DomainDigTests; path = DomainDigTests; @@ -489,6 +492,7 @@ files = ( E959C4D24DAAB3CB80D854B2 /* DiffServiceTests.swift in Sources */, 81359F63C7A23454B8FA0141 /* DomainReportBuilderTests.swift in Sources */, + A1B2C3D40000000000000102 /* HistoryEntryCodableTests.swift in Sources */, 47CD3BB1AE143733A73E0E5B /* DomainReportExporterTests.swift in Sources */, C7CA9E02B0DC2708DE7A8563 /* DomainDataPortabilityServiceTests.swift in Sources */, A5AF921BC1C2E6E940CC05DC /* SnapshotFixture.swift in Sources */, @@ -364,7 +364,7 @@ struct DNSLookupService { answers.append(.init( name: name, type: type, - TTL: ttl, + ttl: ttl, data: parsedValue )) } @@ -520,66 +520,80 @@ final class DomainMonitoringService { } let entry = HistoryEntry( - domain: snapshot.domain, - timestamp: snapshot.timestamp, - trackedDomainID: trackedDomainID, - note: trackedDomains.first(where: { $0.id == trackedDomainID })?.note, - dnsSections: snapshot.dnsSections, - sslInfo: snapshot.sslInfo, - httpHeaders: snapshot.httpHeaders, - reachabilityResults: snapshot.reachabilityResults, - ipGeolocation: snapshot.ipGeolocation, - emailSecurity: snapshot.emailSecurity, - mtaSts: snapshot.emailSecurity?.mtaSts, - ownership: snapshot.ownership, - ownershipHistory: snapshot.ownershipHistory, - ptrRecord: snapshot.ptrRecord, - redirectChain: snapshot.redirectChain, - subdomains: snapshot.subdomains, - extendedSubdomains: snapshot.extendedSubdomains, - dnsHistory: snapshot.dnsHistory, - domainPricing: snapshot.domainPricing, - reputation: snapshot.reputation, - portScanResults: snapshot.portScanResults, - hstsPreloaded: snapshot.hstsPreloaded, - availabilityResult: snapshot.availabilityResult, - suggestions: snapshot.suggestions, - appVersion: snapshot.appVersion, - resultSource: snapshot.resultSource, - dataSources: snapshot.dataSources, - provenanceBySection: snapshot.provenanceBySection, - availabilityConfidence: snapshot.availabilityConfidence, - ownershipConfidence: snapshot.ownershipConfidence, - subdomainConfidence: snapshot.subdomainConfidence, - emailSecurityConfidence: snapshot.emailSecurityConfidence, - geolocationConfidence: snapshot.geolocationConfidence, - errorDetails: snapshot.errorDetails, - isPartialSnapshot: snapshot.isPartialSnapshot, - validationIssues: snapshot.validationIssues, - resolverDisplayName: snapshot.resolverDisplayName, - resolverURLString: snapshot.resolverURLString, - totalLookupDurationMs: snapshot.totalLookupDurationMs, - primaryIP: Self.primaryIPAddress(from: snapshot), - finalRedirectURL: snapshot.redirectChain.last?.url, - tlsStatusSummary: Self.tlsSummary(from: snapshot), - emailSecuritySummary: Self.emailSummary(from: snapshot), - httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError, - changeSummary: changeSummary, - sslError: snapshot.sslError, - httpHeadersError: snapshot.httpHeadersError, - reachabilityError: snapshot.reachabilityError, - ipGeolocationError: snapshot.ipGeolocationError, - emailSecurityError: snapshot.emailSecurityError, - ownershipError: snapshot.ownershipError, - ownershipHistoryError: snapshot.ownershipHistoryError, - ptrError: snapshot.ptrError, - redirectChainError: snapshot.redirectChainError, - subdomainsError: snapshot.subdomainsError, - extendedSubdomainsError: snapshot.extendedSubdomainsError, - dnsHistoryError: snapshot.dnsHistoryError, - domainPricingError: snapshot.domainPricingError, - reputationError: snapshot.reputationError, - portScanError: snapshot.portScanError + identity: HistoryEntry.Identity( + domain: snapshot.domain, + timestamp: snapshot.timestamp, + trackedDomainID: trackedDomainID, + note: trackedDomains.first(where: { $0.id == trackedDomainID })?.note + ), + inspection: HistoryEntry.Inspection( + dnsSections: snapshot.dnsSections, + sslInfo: snapshot.sslInfo, + httpHeaders: snapshot.httpHeaders, + reachabilityResults: snapshot.reachabilityResults, + ipGeolocation: snapshot.ipGeolocation, + emailSecurity: snapshot.emailSecurity, + mtaSts: snapshot.emailSecurity?.mtaSts, + ptrRecord: snapshot.ptrRecord, + redirectChain: snapshot.redirectChain, + subdomains: snapshot.subdomains, + extendedSubdomains: snapshot.extendedSubdomains, + dnsHistory: snapshot.dnsHistory, + portScanResults: snapshot.portScanResults, + hstsPreloaded: snapshot.hstsPreloaded, + availabilityResult: snapshot.availabilityResult, + suggestions: snapshot.suggestions + ), + registration: HistoryEntry.Registration( + ownership: snapshot.ownership, + ownershipHistory: snapshot.ownershipHistory, + domainPricing: snapshot.domainPricing + ), + intelligence: HistoryEntry.Intelligence( + reputation: snapshot.reputation + ), + provenance: HistoryEntry.Provenance( + appVersion: snapshot.appVersion, + resultSource: snapshot.resultSource, + dataSources: snapshot.dataSources, + provenanceBySection: snapshot.provenanceBySection, + availabilityConfidence: snapshot.availabilityConfidence, + ownershipConfidence: snapshot.ownershipConfidence, + subdomainConfidence: snapshot.subdomainConfidence, + emailSecurityConfidence: snapshot.emailSecurityConfidence, + geolocationConfidence: snapshot.geolocationConfidence, + isPartialSnapshot: snapshot.isPartialSnapshot, + validationIssues: snapshot.validationIssues, + resolverDisplayName: snapshot.resolverDisplayName, + resolverURLString: snapshot.resolverURLString, + totalLookupDurationMs: snapshot.totalLookupDurationMs + ), + summary: HistoryEntry.Summary( + primaryIP: Self.primaryIPAddress(from: snapshot), + finalRedirectURL: snapshot.redirectChain.last?.url, + tlsStatusSummary: Self.tlsSummary(from: snapshot), + emailSecuritySummary: Self.emailSummary(from: snapshot), + httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError, + changeSummary: changeSummary + ), + failures: HistoryEntry.Failures( + errorDetails: snapshot.errorDetails, + sslError: snapshot.sslError, + httpHeadersError: snapshot.httpHeadersError, + reachabilityError: snapshot.reachabilityError, + ipGeolocationError: snapshot.ipGeolocationError, + emailSecurityError: snapshot.emailSecurityError, + ownershipError: snapshot.ownershipError, + ownershipHistoryError: snapshot.ownershipHistoryError, + ptrError: snapshot.ptrError, + redirectChainError: snapshot.redirectChainError, + subdomainsError: snapshot.subdomainsError, + extendedSubdomainsError: snapshot.extendedSubdomainsError, + dnsHistoryError: snapshot.dnsHistoryError, + domainPricingError: snapshot.domainPricingError, + reputationError: snapshot.reputationError, + portScanError: snapshot.portScanError + ) ) history.insert(entry, at: 0) @@ -1936,78 +1936,92 @@ final class DomainViewModel { } let entry = HistoryEntry( - domain: snapshot.domain, - timestamp: snapshot.timestamp, - trackedDomainID: trackedDomainID, - note: currentHistoryEntry?.note, - dnsSections: snapshot.dnsSections, - sslInfo: snapshot.sslInfo, - httpHeaders: snapshot.httpHeaders, - reachabilityResults: snapshot.reachabilityResults, - ipGeolocation: snapshot.ipGeolocation, - emailSecurity: snapshot.emailSecurity, - mtaSts: snapshot.emailSecurity?.mtaSts, - ownership: snapshot.ownership, - ownershipHistory: snapshot.ownershipHistory, - inferredProvider: intelligence.inferredProvider, - priorProviders: intelligence.priorProviders, - domainClassification: intelligence.domainClassification, - ownershipTransitions: intelligence.ownershipTransitions, - hostingTransitions: intelligence.hostingTransitions, - subdomainHistory: intelligence.subdomainHistory, - riskSignals: intelligence.riskSignals, - intelligenceTimeline: intelligence.timelineEvents, - ptrRecord: snapshot.ptrRecord, - redirectChain: snapshot.redirectChain, - subdomains: snapshot.subdomains, - extendedSubdomains: snapshot.extendedSubdomains, - dnsHistory: snapshot.dnsHistory, - domainPricing: snapshot.domainPricing, - reputation: snapshot.reputation, - portScanResults: snapshot.portScanResults, - hstsPreloaded: snapshot.hstsPreloaded, - availabilityResult: snapshot.availabilityResult, - suggestions: snapshot.suggestions, - appVersion: snapshot.appVersion, - resultSource: snapshot.resultSource, - dataSources: snapshot.dataSources, - provenanceBySection: snapshot.provenanceBySection, - availabilityConfidence: snapshot.availabilityConfidence, - ownershipConfidence: snapshot.ownershipConfidence, - subdomainConfidence: snapshot.subdomainConfidence, - emailSecurityConfidence: snapshot.emailSecurityConfidence, - geolocationConfidence: snapshot.geolocationConfidence, - errorDetails: snapshot.errorDetails, - isPartialSnapshot: snapshot.isPartialSnapshot, - validationIssues: snapshot.validationIssues, - resolverDisplayName: snapshot.resolverDisplayName, - resolverURLString: snapshot.resolverURLString, - totalLookupDurationMs: snapshot.totalLookupDurationMs, - primaryIP: Self.primaryIPAddress(from: snapshot), - finalRedirectURL: Self.finalRedirectTarget(from: snapshot), - tlsStatusSummary: Self.httpsSummary(from: snapshot), - emailSecuritySummary: Self.emailSummary(from: snapshot), - httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError, - changeSummary: changeSummary, - snapshotIndex: nextSnapshotIndex, - previousSnapshotID: previousSnapshotID, - changeCount: domainDiff?.changeCount ?? changeSummary?.changedSections.count ?? 0, - severitySummary: changeSummary?.severity, - sslError: snapshot.sslError, - httpHeadersError: snapshot.httpHeadersError, - reachabilityError: snapshot.reachabilityError, - ipGeolocationError: snapshot.ipGeolocationError, - emailSecurityError: snapshot.emailSecurityError, - ownershipError: snapshot.ownershipError, - ownershipHistoryError: snapshot.ownershipHistoryError, - ptrError: snapshot.ptrError, - redirectChainError: snapshot.redirectChainError, - subdomainsError: snapshot.subdomainsError, - extendedSubdomainsError: snapshot.extendedSubdomainsError, - dnsHistoryError: snapshot.dnsHistoryError, - domainPricingError: snapshot.domainPricingError, - reputationError: snapshot.reputationError, - portScanError: snapshot.portScanError + identity: HistoryEntry.Identity( + domain: snapshot.domain, + timestamp: snapshot.timestamp, + trackedDomainID: trackedDomainID, + note: currentHistoryEntry?.note + ), + inspection: HistoryEntry.Inspection( + dnsSections: snapshot.dnsSections, + sslInfo: snapshot.sslInfo, + httpHeaders: snapshot.httpHeaders, + reachabilityResults: snapshot.reachabilityResults, + ipGeolocation: snapshot.ipGeolocation, + emailSecurity: snapshot.emailSecurity, + mtaSts: snapshot.emailSecurity?.mtaSts, + ptrRecord: snapshot.ptrRecord, + redirectChain: snapshot.redirectChain, + subdomains: snapshot.subdomains, + extendedSubdomains: snapshot.extendedSubdomains, + dnsHistory: snapshot.dnsHistory, + portScanResults: snapshot.portScanResults, + hstsPreloaded: snapshot.hstsPreloaded, + availabilityResult: snapshot.availabilityResult, + suggestions: snapshot.suggestions + ), + registration: HistoryEntry.Registration( + ownership: snapshot.ownership, + ownershipHistory: snapshot.ownershipHistory, + inferredProvider: intelligence.inferredProvider, + priorProviders: intelligence.priorProviders, + domainClassification: intelligence.domainClassification, + ownershipTransitions: intelligence.ownershipTransitions, + hostingTransitions: intelligence.hostingTransitions, + domainPricing: snapshot.domainPricing + ), + intelligence: HistoryEntry.Intelligence( + subdomainHistory: intelligence.subdomainHistory, + riskSignals: intelligence.riskSignals, + intelligenceTimeline: intelligence.timelineEvents, + reputation: snapshot.reputation + ), + provenance: HistoryEntry.Provenance( + appVersion: snapshot.appVersion, + resultSource: snapshot.resultSource, + dataSources: snapshot.dataSources, + provenanceBySection: snapshot.provenanceBySection, + availabilityConfidence: snapshot.availabilityConfidence, + ownershipConfidence: snapshot.ownershipConfidence, + subdomainConfidence: snapshot.subdomainConfidence, + emailSecurityConfidence: snapshot.emailSecurityConfidence, + geolocationConfidence: snapshot.geolocationConfidence, + isPartialSnapshot: snapshot.isPartialSnapshot, + validationIssues: snapshot.validationIssues, + resolverDisplayName: snapshot.resolverDisplayName, + resolverURLString: snapshot.resolverURLString, + totalLookupDurationMs: snapshot.totalLookupDurationMs + ), + summary: HistoryEntry.Summary( + primaryIP: Self.primaryIPAddress(from: snapshot), + finalRedirectURL: Self.finalRedirectTarget(from: snapshot), + tlsStatusSummary: Self.httpsSummary(from: snapshot), + emailSecuritySummary: Self.emailSummary(from: snapshot), + httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError, + changeSummary: changeSummary, + snapshotIndex: nextSnapshotIndex, + previousSnapshotID: previousSnapshotID, + changeCount: domainDiff?.changeCount ?? changeSummary?.changedSections.count ?? 0, + severitySummary: changeSummary?.severity + ), + failures: HistoryEntry.Failures( + errorDetails: snapshot.errorDetails, + sslError: snapshot.sslError, + httpHeadersError: snapshot.httpHeadersError, + reachabilityError: snapshot.reachabilityError, + ipGeolocationError: snapshot.ipGeolocationError, + emailSecurityError: snapshot.emailSecurityError, + ownershipError: snapshot.ownershipError, + ownershipHistoryError: snapshot.ownershipHistoryError, + ptrError: snapshot.ptrError, + redirectChainError: snapshot.redirectChainError, + subdomainsError: snapshot.subdomainsError, + extendedSubdomainsError: snapshot.extendedSubdomainsError, + dnsHistoryError: snapshot.dnsHistoryError, + domainPricingError: snapshot.domainPricingError, + reputationError: snapshot.reputationError, + portScanError: snapshot.portScanError + ) ) if updateCurrentState { @@ -2399,113 +2399,196 @@ struct HistoryEntry: Identifiable, Codable { var reputationError: String? var portScanError: String? - init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, note: String? = nil, dnsSections: [DNSSection], - sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader], - reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, - emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ownership: DomainOwnership? = nil, - ownershipHistory: [DomainOwnershipHistoryEvent] = [], - inferredProvider: InferredProviderFingerprint? = nil, priorProviders: [String] = [], - domainClassification: DomainClassificationSummary? = nil, - ownershipTransitions: [OwnershipTransitionEvent] = [], - hostingTransitions: [HostingTransitionEvent] = [], - subdomainHistory: [SubdomainHistoryEntry] = [], - riskSignals: [IntelligenceRiskSignal] = [], - intelligenceTimeline: [IntelligenceTimelineEvent] = [], - ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [], - extendedSubdomains: [DiscoveredSubdomain] = [], dnsHistory: [DNSHistoryEvent] = [], - domainPricing: DomainPricingInsight? = nil, - reputation: DomainReputationResult? = nil, - portScanResults: [PortScanResult] = [], - hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil, - suggestions: [DomainSuggestionResult] = [], appVersion: String = "2.7.0", - resultSource: LookupResultSource = .snapshot, dataSources: [String] = [], - provenanceBySection: [LookupSectionKind: SectionProvenance] = [:], - availabilityConfidence: ConfidenceLevel? = nil, ownershipConfidence: ConfidenceLevel? = nil, - subdomainConfidence: ConfidenceLevel? = nil, emailSecurityConfidence: ConfidenceLevel? = nil, - geolocationConfidence: ConfidenceLevel? = nil, - errorDetails: [LookupSectionKind: InspectionFailure] = [:], isPartialSnapshot: Bool = false, - validationIssues: [String] = [], resolverDisplayName: String, resolverURLString: String, - totalLookupDurationMs: Int? = nil, primaryIP: String? = nil, finalRedirectURL: String? = nil, - tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil, - changeSummary: DomainChangeSummary? = nil, snapshotIndex: Int? = nil, previousSnapshotID: UUID? = nil, - changeCount: Int = 0, severitySummary: ChangeSeverity? = nil, sslError: String? = nil, httpHeadersError: String? = nil, - reachabilityError: String? = nil, ipGeolocationError: String? = nil, - emailSecurityError: String? = nil, ownershipError: String? = nil, ownershipHistoryError: String? = nil, - ptrError: String? = nil, redirectChainError: String? = nil, subdomainsError: String? = nil, - extendedSubdomainsError: String? = nil, dnsHistoryError: String? = nil, - domainPricingError: String? = nil, reputationError: String? = nil, portScanError: String? = nil) { - self.domain = domain - self.timestamp = timestamp - self.trackedDomainID = trackedDomainID - self.note = note - self.dnsSections = dnsSections - self.sslInfo = sslInfo - self.httpHeaders = httpHeaders - self.reachabilityResults = reachabilityResults - self.ipGeolocation = ipGeolocation - self.emailSecurity = emailSecurity - self.mtaSts = mtaSts ?? emailSecurity?.mtaSts - self.ownership = ownership - self.ownershipHistory = ownershipHistory - self.inferredProvider = inferredProvider - self.priorProviders = priorProviders - self.domainClassification = domainClassification - self.ownershipTransitions = ownershipTransitions - self.hostingTransitions = hostingTransitions - self.subdomainHistory = subdomainHistory - self.riskSignals = riskSignals - self.intelligenceTimeline = intelligenceTimeline - self.ptrRecord = ptrRecord - self.redirectChain = redirectChain - self.subdomains = subdomains - self.extendedSubdomains = extendedSubdomains - self.dnsHistory = dnsHistory - self.domainPricing = domainPricing - self.reputation = reputation - self.portScanResults = portScanResults - self.hstsPreloaded = hstsPreloaded - self.availabilityResult = availabilityResult - self.suggestions = suggestions - self.appVersion = appVersion - self.resultSource = resultSource - self.dataSources = dataSources - self.provenanceBySection = provenanceBySection - self.availabilityConfidence = availabilityConfidence - self.ownershipConfidence = ownershipConfidence - self.subdomainConfidence = subdomainConfidence - self.emailSecurityConfidence = emailSecurityConfidence - self.geolocationConfidence = geolocationConfidence - self.errorDetails = errorDetails - self.isPartialSnapshot = isPartialSnapshot - self.validationIssues = validationIssues - self.resolverDisplayName = resolverDisplayName - self.resolverURLString = resolverURLString - self.totalLookupDurationMs = totalLookupDurationMs - self.primaryIP = primaryIP - self.finalRedirectURL = finalRedirectURL - self.tlsStatusSummary = tlsStatusSummary - self.emailSecuritySummary = emailSecuritySummary - self.httpGradeSummary = httpGradeSummary - self.changeSummary = changeSummary - self.snapshotIndex = snapshotIndex - self.previousSnapshotID = previousSnapshotID - self.changeCount = changeCount - self.severitySummary = severitySummary - self.sslError = sslError - self.httpHeadersError = httpHeadersError - self.reachabilityError = reachabilityError - self.ipGeolocationError = ipGeolocationError - self.emailSecurityError = emailSecurityError - self.ownershipError = ownershipError - self.ownershipHistoryError = ownershipHistoryError - self.ptrError = ptrError - self.redirectChainError = redirectChainError - self.subdomainsError = subdomainsError - self.extendedSubdomainsError = extendedSubdomainsError - self.dnsHistoryError = dnsHistoryError - self.domainPricingError = domainPricingError - self.reputationError = reputationError - self.portScanError = portScanError + // A full inspection produces 73 stored properties, and passing them as one + // flat argument list made a 72-parameter initializer no call site could read + // (SonarCloud swift:S107). They are grouped below by what they describe. + // + // The stored properties stay flat, deliberately. This type is persisted to + // UserDefaults and read back out of backup files, and both decode paths drop + // entries that will not parse rather than raising — so nesting a value would + // change the encoded shape and silently discard history written by an older + // build. These groups exist at the call boundary only; the JSON is unchanged, + // which HistoryEntryCodableTests pins. + + /// What was inspected, and when. + struct Identity { + var domain: String + var timestamp: Date + var trackedDomainID: UUID? = nil + var note: String? = nil + } + + /// The live probe results for this snapshot. + struct Inspection { + var dnsSections: [DNSSection] + var sslInfo: SSLCertificateInfo? + var httpHeaders: [HTTPHeader] + var reachabilityResults: [PortReachability] + var ipGeolocation: IPGeolocation? + var emailSecurity: EmailSecurityResult? = nil + var mtaSts: MTASTSResult? = nil + var ptrRecord: String? = nil + var redirectChain: [RedirectHop] = [] + var subdomains: [DiscoveredSubdomain] = [] + var extendedSubdomains: [DiscoveredSubdomain] = [] + var dnsHistory: [DNSHistoryEvent] = [] + var portScanResults: [PortScanResult] = [] + var hstsPreloaded: Bool? = nil + var availabilityResult: DomainAvailabilityResult? = nil + var suggestions: [DomainSuggestionResult] = [] + } + + /// Who owns the domain and where it is hosted. + struct Registration { + var ownership: DomainOwnership? = nil + var ownershipHistory: [DomainOwnershipHistoryEvent] = [] + var inferredProvider: InferredProviderFingerprint? = nil + var priorProviders: [String] = [] + var domainClassification: DomainClassificationSummary? = nil + var ownershipTransitions: [OwnershipTransitionEvent] = [] + var hostingTransitions: [HostingTransitionEvent] = [] + var domainPricing: DomainPricingInsight? = nil + } + + /// Derived signals rather than direct observations. + struct Intelligence { + var subdomainHistory: [SubdomainHistoryEntry] = [] + var riskSignals: [IntelligenceRiskSignal] = [] + var intelligenceTimeline: [IntelligenceTimelineEvent] = [] + var reputation: DomainReputationResult? = nil + } + + /// Where the data came from, and how much to trust it. + struct Provenance { + var appVersion: String = "2.7.0" + var resultSource: LookupResultSource = .snapshot + var dataSources: [String] = [] + var provenanceBySection: [LookupSectionKind: SectionProvenance] = [:] + var availabilityConfidence: ConfidenceLevel? = nil + var ownershipConfidence: ConfidenceLevel? = nil + var subdomainConfidence: ConfidenceLevel? = nil + var emailSecurityConfidence: ConfidenceLevel? = nil + var geolocationConfidence: ConfidenceLevel? = nil + var isPartialSnapshot: Bool = false + var validationIssues: [String] = [] + var resolverDisplayName: String + var resolverURLString: String + var totalLookupDurationMs: Int? = nil + } + + /// Precomputed display values and change tracking. + struct Summary { + var primaryIP: String? = nil + var finalRedirectURL: String? = nil + var tlsStatusSummary: String? = nil + var emailSecuritySummary: String? = nil + var httpGradeSummary: String? = nil + var changeSummary: DomainChangeSummary? = nil + var snapshotIndex: Int? = nil + var previousSnapshotID: UUID? = nil + var changeCount: Int = 0 + var severitySummary: ChangeSeverity? = nil + } + + /// Per-section failures: a snapshot records what it could not collect. + struct Failures { + var errorDetails: [LookupSectionKind: InspectionFailure] = [:] + var sslError: String? = nil + var httpHeadersError: String? = nil + var reachabilityError: String? = nil + var ipGeolocationError: String? = nil + var emailSecurityError: String? = nil + var ownershipError: String? = nil + var ownershipHistoryError: String? = nil + var ptrError: String? = nil + var redirectChainError: String? = nil + var subdomainsError: String? = nil + var extendedSubdomainsError: String? = nil + var dnsHistoryError: String? = nil + var domainPricingError: String? = nil + var reputationError: String? = nil + var portScanError: String? = nil + } + + init(identity: Identity, + inspection: Inspection, + registration: Registration = Registration(), + intelligence: Intelligence = Intelligence(), + provenance: Provenance, + summary: Summary = Summary(), + failures: Failures = Failures()) { + self.domain = identity.domain + self.timestamp = identity.timestamp + self.trackedDomainID = identity.trackedDomainID + self.note = identity.note + self.dnsSections = inspection.dnsSections + self.sslInfo = inspection.sslInfo + self.httpHeaders = inspection.httpHeaders + self.reachabilityResults = inspection.reachabilityResults + self.ipGeolocation = inspection.ipGeolocation + self.emailSecurity = inspection.emailSecurity + self.mtaSts = inspection.mtaSts ?? inspection.emailSecurity?.mtaSts + self.ptrRecord = inspection.ptrRecord + self.redirectChain = inspection.redirectChain + self.subdomains = inspection.subdomains + self.extendedSubdomains = inspection.extendedSubdomains + self.dnsHistory = inspection.dnsHistory + self.portScanResults = inspection.portScanResults + self.hstsPreloaded = inspection.hstsPreloaded + self.availabilityResult = inspection.availabilityResult + self.suggestions = inspection.suggestions + self.ownership = registration.ownership + self.ownershipHistory = registration.ownershipHistory + self.inferredProvider = registration.inferredProvider + self.priorProviders = registration.priorProviders + self.domainClassification = registration.domainClassification + self.ownershipTransitions = registration.ownershipTransitions + self.hostingTransitions = registration.hostingTransitions + self.domainPricing = registration.domainPricing + self.subdomainHistory = intelligence.subdomainHistory + self.riskSignals = intelligence.riskSignals + self.intelligenceTimeline = intelligence.intelligenceTimeline + self.reputation = intelligence.reputation + self.appVersion = provenance.appVersion + self.resultSource = provenance.resultSource + self.dataSources = provenance.dataSources + self.provenanceBySection = provenance.provenanceBySection + self.availabilityConfidence = provenance.availabilityConfidence + self.ownershipConfidence = provenance.ownershipConfidence + self.subdomainConfidence = provenance.subdomainConfidence + self.emailSecurityConfidence = provenance.emailSecurityConfidence + self.geolocationConfidence = provenance.geolocationConfidence + self.isPartialSnapshot = provenance.isPartialSnapshot + self.validationIssues = provenance.validationIssues + self.resolverDisplayName = provenance.resolverDisplayName + self.resolverURLString = provenance.resolverURLString + self.totalLookupDurationMs = provenance.totalLookupDurationMs + self.primaryIP = summary.primaryIP + self.finalRedirectURL = summary.finalRedirectURL + self.tlsStatusSummary = summary.tlsStatusSummary + self.emailSecuritySummary = summary.emailSecuritySummary + self.httpGradeSummary = summary.httpGradeSummary + self.changeSummary = summary.changeSummary + self.snapshotIndex = summary.snapshotIndex + self.previousSnapshotID = summary.previousSnapshotID + self.changeCount = summary.changeCount + self.severitySummary = summary.severitySummary + self.errorDetails = failures.errorDetails + self.sslError = failures.sslError + self.httpHeadersError = failures.httpHeadersError + self.reachabilityError = failures.reachabilityError + self.ipGeolocationError = failures.ipGeolocationError + self.emailSecurityError = failures.emailSecurityError + self.ownershipError = failures.ownershipError + self.ownershipHistoryError = failures.ownershipHistoryError + self.ptrError = failures.ptrError + self.redirectChainError = failures.redirectChainError + self.subdomainsError = failures.subdomainsError + self.extendedSubdomainsError = failures.extendedSubdomainsError + self.dnsHistoryError = failures.dnsHistoryError + self.domainPricingError = failures.domainPricingError + self.reputationError = failures.reputationError + self.portScanError = failures.portScanError } init(from decoder: Decoder) throws { new file mode 100644 @@ -0,0 +1,129 @@ +import XCTest +@testable import DomainDig + +/// Characterization tests pinning `HistoryEntry`'s encoded shape. +/// +/// This type is not merely an in-memory model: `DomainDataPortabilityService` +/// persists it to `UserDefaults` as JSON and reads the same shape back out of +/// backup files. Both decode paths swallow failures — `loadHistoryEntries` uses +/// `try?` and drops anything that will not decode — so a change to the encoded +/// keys does not raise, it silently discards a user's saved history. +/// +/// These tests exist so that any such change fails here first, loudly, instead +/// of in someone's app. +final class HistoryEntryCodableTests: XCTestCase { + /// Every key `HistoryEntry` is expected to encode. Adding a stored property + /// is a format change: extend this list deliberately, and only once you have + /// decided what happens to history written by an older build. + private static let expectedKeys: Set<String> = [ + "id", "domain", "timestamp", "trackedDomainID", "note", "dnsSections", + "sslInfo", "httpHeaders", "reachabilityResults", "ipGeolocation", + "emailSecurity", "mtaSts", "ownership", "ownershipHistory", + "inferredProvider", "priorProviders", "domainClassification", + "ownershipTransitions", "hostingTransitions", "subdomainHistory", + "riskSignals", "intelligenceTimeline", "ptrRecord", "redirectChain", + "subdomains", "extendedSubdomains", "dnsHistory", "domainPricing", + "reputation", "portScanResults", "hstsPreloaded", "availabilityResult", + "suggestions", "appVersion", "resultSource", "dataSources", + "provenanceBySection", "availabilityConfidence", "ownershipConfidence", + "subdomainConfidence", "emailSecurityConfidence", "geolocationConfidence", + "errorDetails", "isPartialSnapshot", "validationIssues", + "resolverDisplayName", "resolverURLString", "totalLookupDurationMs", + "primaryIP", "finalRedirectURL", "tlsStatusSummary", + "emailSecuritySummary", "httpGradeSummary", "changeSummary", + "snapshotIndex", "previousSnapshotID", "changeCount", "severitySummary", + "sslError", "httpHeadersError", "reachabilityError", "ipGeolocationError", + "emailSecurityError", "ownershipError", "ownershipHistoryError", + "ptrError", "redirectChainError", "subdomainsError", + "extendedSubdomainsError", "dnsHistoryError", "domainPricingError", + "reputationError", "portScanError", + ] + + private func makeEntry() -> HistoryEntry { + HistoryEntry( + identity: .init( + domain: "example.com", + timestamp: Date(timeIntervalSince1970: 1_700_000_000) + ), + inspection: .init( + dnsSections: [], + sslInfo: nil, + httpHeaders: [], + reachabilityResults: [], + ipGeolocation: nil + ), + provenance: .init( + resolverDisplayName: "Test Resolver", + resolverURLString: "https://resolver.example/dns-query" + ) + ) + } + + private func encoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + return encoder + } + + /// The set of top-level keys is the persisted contract. Optionals that are + /// nil are omitted by the synthesized encoder, so this asserts containment + /// rather than equality — no key may appear that is not accounted for. + func testEncodedKeysAreAllAccountedFor() throws { + let data = try encoder().encode(makeEntry()) + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + + let unexpected = Set(object.keys).subtracting(Self.expectedKeys) + XCTAssertTrue( + unexpected.isEmpty, + "HistoryEntry encoded keys not in the pinned set: \(unexpected.sorted()). " + + "This changes the persisted format; older history will not decode." + ) + } + + /// A populated entry must survive encode → decode → encode unchanged. This + /// is the guard that a refactor which regroups the initializer has not also + /// moved a value into a different place in the JSON. + func testRoundTripIsStable() throws { + let first = try encoder().encode(makeEntry()) + let decoded = try JSONDecoder().decode(HistoryEntry.self, from: first) + let second = try encoder().encode(decoded) + + XCTAssertEqual( + first, second, + "HistoryEntry did not survive a JSON round trip unchanged" + ) + } + + /// The values a caller supplies must land under the keys the persisted format + /// already uses, not merely somewhere in the document. + func testRequiredValuesEncodeAtTheTopLevel() throws { + let data = try encoder().encode(makeEntry()) + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + + XCTAssertEqual(object["domain"] as? String, "example.com") + XCTAssertEqual(object["resolverDisplayName"] as? String, "Test Resolver") + XCTAssertEqual( + object["resolverURLString"] as? String, + "https://resolver.example/dns-query" + ) + } + + /// What `loadHistoryEntries` actually does with a stored blob, end to end: + /// anything that fails to decode is dropped without error, so this pins that + /// a current-format entry survives the real read path. + func testSurvivesTheRealPersistencePath() throws { + let suite = "DomainDigTests.historyEntryCodable" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + + DomainDataPortabilityService.saveHistoryEntries([makeEntry()], defaults: defaults) + let loaded = DomainDataPortabilityService.loadHistoryEntries(defaults: defaults) + + XCTAssertEqual(loaded.count, 1, "entry was silently dropped by the load path") + XCTAssertEqual(loaded.first?.domain, "example.com") + } +}