Commit 1bc0c44013

1bc0c44013e577a8fe61bcef5fd1590c0692a0a4

parent: 72dbfaaabd

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-22 06:33 UTC

Fix the build, and group HistoryEntry's 72-parameter initializer

The build was broken on main. 49a9998 renamed CloudflareDNSAnswer.TTL to .ttl
and added CodingKeys so the JSON is unchanged, but left the call site in
DNSLookupService passing TTL:. CI caught it — the xcodebuild test job on #64
failed — and the PR was merged anyway.

Closes #65. A full inspection produces 73 stored properties, and passing them
as one flat list made a 72-parameter initializer (swift:S107) that no call site
could read. They are grouped into seven structs by what they describe, so both
call sites now name what they are filling in.

The stored properties stay flat, deliberately. HistoryEntry is persisted to
UserDefaults and read back out of backup files, and both decode paths drop
entries that will not parse rather than raising — nesting a value would change
the encoded shape and silently discard history written by an older build. The
groups exist at the call boundary only; CodingKeys, init(from:) and every
stored property are byte for byte unchanged.

HistoryEntryCodableTests pins that: the encoded key set, a JSON round trip, and
a save/load through the real persistence path. It had no test coverage at all
before, which is what made the format easy to break silently.

Layout: unified · split

.gitignore added +3
@@ -0,0 +1,3 @@
1
2# Xcode per-user state
3xcuserdata/
DomainDig.xcodeproj/project.pbxproj +4
@@ -12,6 +12,7 @@
12 47CD3BB1AE143733A73E0E5B /* DomainReportExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D7C7D97A4006831572F468 /* DomainReportExporterTests.swift */; }; 12 47CD3BB1AE143733A73E0E5B /* DomainReportExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D7C7D97A4006831572F468 /* DomainReportExporterTests.swift */; };
13 4F96CEB875EC3501E784CE39 /* DataMigrationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435AFB3F99D579D3D7B58279 /* DataMigrationServiceTests.swift */; }; 13 4F96CEB875EC3501E784CE39 /* DataMigrationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435AFB3F99D579D3D7B58279 /* DataMigrationServiceTests.swift */; };
14 81359F63C7A23454B8FA0141 /* DomainReportBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E82320955416797CFE59464A /* DomainReportBuilderTests.swift */; }; 14 81359F63C7A23454B8FA0141 /* DomainReportBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E82320955416797CFE59464A /* DomainReportBuilderTests.swift */; };
15 A1B2C3D40000000000000102 /* HistoryEntryCodableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000000000101 /* HistoryEntryCodableTests.swift */; };
15 8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */; }; 16 8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */; };
16 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; }; 17 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; };
17 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; }; 18 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; };
@@ -98,6 +99,7 @@
98 CC948A02EC0184228BC4630E /* DomainDataPortabilityServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DomainDataPortabilityServiceTests.swift; sourceTree = "<group>"; }; 99 CC948A02EC0184228BC4630E /* DomainDataPortabilityServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DomainDataPortabilityServiceTests.swift; sourceTree = "<group>"; };
99 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; }; 100 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; };
100 E82320955416797CFE59464A /* DomainReportBuilderTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DomainReportBuilderTests.swift; sourceTree = "<group>"; }; 101 E82320955416797CFE59464A /* DomainReportBuilderTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DomainReportBuilderTests.swift; sourceTree = "<group>"; };
102 A1B2C3D40000000000000101 /* HistoryEntryCodableTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HistoryEntryCodableTests.swift; sourceTree = "<group>"; };
101/* End PBXFileReference section */ 103/* End PBXFileReference section */
102 104
103/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ 105/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -235,6 +237,7 @@
235 0D85A44C5F315A1644AC9073 /* LocalAPIContractTests.swift */, 237 0D85A44C5F315A1644AC9073 /* LocalAPIContractTests.swift */,
236 435AFB3F99D579D3D7B58279 /* DataMigrationServiceTests.swift */, 238 435AFB3F99D579D3D7B58279 /* DataMigrationServiceTests.swift */,
237 499B99A1866999A38AC475F5 /* AppInfoTests.swift */, 239 499B99A1866999A38AC475F5 /* AppInfoTests.swift */,
240 A1B2C3D40000000000000101 /* HistoryEntryCodableTests.swift */,
238 ); 241 );
239 name = DomainDigTests; 242 name = DomainDigTests;
240 path = DomainDigTests; 243 path = DomainDigTests;
@@ -489,6 +492,7 @@
489 files = ( 492 files = (
490 E959C4D24DAAB3CB80D854B2 /* DiffServiceTests.swift in Sources */, 493 E959C4D24DAAB3CB80D854B2 /* DiffServiceTests.swift in Sources */,
491 81359F63C7A23454B8FA0141 /* DomainReportBuilderTests.swift in Sources */, 494 81359F63C7A23454B8FA0141 /* DomainReportBuilderTests.swift in Sources */,
495 A1B2C3D40000000000000102 /* HistoryEntryCodableTests.swift in Sources */,
492 47CD3BB1AE143733A73E0E5B /* DomainReportExporterTests.swift in Sources */, 496 47CD3BB1AE143733A73E0E5B /* DomainReportExporterTests.swift in Sources */,
493 C7CA9E02B0DC2708DE7A8563 /* DomainDataPortabilityServiceTests.swift in Sources */, 497 C7CA9E02B0DC2708DE7A8563 /* DomainDataPortabilityServiceTests.swift in Sources */,
494 A5AF921BC1C2E6E940CC05DC /* SnapshotFixture.swift in Sources */, 498 A5AF921BC1C2E6E940CC05DC /* SnapshotFixture.swift in Sources */,
DomainDig/DNSLookupService.swift +1 −1
@@ -364,7 +364,7 @@ struct DNSLookupService {
364 answers.append(.init( 364 answers.append(.init(
365 name: name, 365 name: name,
366 type: type, 366 type: type,
367 TTL: ttl, 367 ttl: ttl,
368 data: parsedValue 368 data: parsedValue
369 )) 369 ))
370 } 370 }
DomainDig/DomainMonitoringService.swift +74 −60
@@ -520,66 +520,80 @@ final class DomainMonitoringService {
520 } 520 }
521 521
522 let entry = HistoryEntry( 522 let entry = HistoryEntry(
523 domain: snapshot.domain, 523 identity: HistoryEntry.Identity(
524 timestamp: snapshot.timestamp, 524 domain: snapshot.domain,
525 trackedDomainID: trackedDomainID, 525 timestamp: snapshot.timestamp,
526 note: trackedDomains.first(where: { $0.id == trackedDomainID })?.note, 526 trackedDomainID: trackedDomainID,
527 dnsSections: snapshot.dnsSections, 527 note: trackedDomains.first(where: { $0.id == trackedDomainID })?.note
528 sslInfo: snapshot.sslInfo, 528 ),
529 httpHeaders: snapshot.httpHeaders, 529 inspection: HistoryEntry.Inspection(
530 reachabilityResults: snapshot.reachabilityResults, 530 dnsSections: snapshot.dnsSections,
531 ipGeolocation: snapshot.ipGeolocation, 531 sslInfo: snapshot.sslInfo,
532 emailSecurity: snapshot.emailSecurity, 532 httpHeaders: snapshot.httpHeaders,
533 mtaSts: snapshot.emailSecurity?.mtaSts, 533 reachabilityResults: snapshot.reachabilityResults,
534 ownership: snapshot.ownership, 534 ipGeolocation: snapshot.ipGeolocation,
535 ownershipHistory: snapshot.ownershipHistory, 535 emailSecurity: snapshot.emailSecurity,
536 ptrRecord: snapshot.ptrRecord, 536 mtaSts: snapshot.emailSecurity?.mtaSts,
537 redirectChain: snapshot.redirectChain, 537 ptrRecord: snapshot.ptrRecord,
538 subdomains: snapshot.subdomains, 538 redirectChain: snapshot.redirectChain,
539 extendedSubdomains: snapshot.extendedSubdomains, 539 subdomains: snapshot.subdomains,
540 dnsHistory: snapshot.dnsHistory, 540 extendedSubdomains: snapshot.extendedSubdomains,
541 domainPricing: snapshot.domainPricing, 541 dnsHistory: snapshot.dnsHistory,
542 reputation: snapshot.reputation, 542 portScanResults: snapshot.portScanResults,
543 portScanResults: snapshot.portScanResults, 543 hstsPreloaded: snapshot.hstsPreloaded,
544 hstsPreloaded: snapshot.hstsPreloaded, 544 availabilityResult: snapshot.availabilityResult,
545 availabilityResult: snapshot.availabilityResult, 545 suggestions: snapshot.suggestions
546 suggestions: snapshot.suggestions, 546 ),
547 appVersion: snapshot.appVersion, 547 registration: HistoryEntry.Registration(
548 resultSource: snapshot.resultSource, 548 ownership: snapshot.ownership,
549 dataSources: snapshot.dataSources, 549 ownershipHistory: snapshot.ownershipHistory,
550 provenanceBySection: snapshot.provenanceBySection, 550 domainPricing: snapshot.domainPricing
551 availabilityConfidence: snapshot.availabilityConfidence, 551 ),
552 ownershipConfidence: snapshot.ownershipConfidence, 552 intelligence: HistoryEntry.Intelligence(
553 subdomainConfidence: snapshot.subdomainConfidence, 553 reputation: snapshot.reputation
554 emailSecurityConfidence: snapshot.emailSecurityConfidence, 554 ),
555 geolocationConfidence: snapshot.geolocationConfidence, 555 provenance: HistoryEntry.Provenance(
556 errorDetails: snapshot.errorDetails, 556 appVersion: snapshot.appVersion,
557 isPartialSnapshot: snapshot.isPartialSnapshot, 557 resultSource: snapshot.resultSource,
558 validationIssues: snapshot.validationIssues, 558 dataSources: snapshot.dataSources,
559 resolverDisplayName: snapshot.resolverDisplayName, 559 provenanceBySection: snapshot.provenanceBySection,
560 resolverURLString: snapshot.resolverURLString, 560 availabilityConfidence: snapshot.availabilityConfidence,
561 totalLookupDurationMs: snapshot.totalLookupDurationMs, 561 ownershipConfidence: snapshot.ownershipConfidence,
562 primaryIP: Self.primaryIPAddress(from: snapshot), 562 subdomainConfidence: snapshot.subdomainConfidence,
563 finalRedirectURL: snapshot.redirectChain.last?.url, 563 emailSecurityConfidence: snapshot.emailSecurityConfidence,
564 tlsStatusSummary: Self.tlsSummary(from: snapshot), 564 geolocationConfidence: snapshot.geolocationConfidence,
565 emailSecuritySummary: Self.emailSummary(from: snapshot), 565 isPartialSnapshot: snapshot.isPartialSnapshot,
566 httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError, 566 validationIssues: snapshot.validationIssues,
567 changeSummary: changeSummary, 567 resolverDisplayName: snapshot.resolverDisplayName,
568 sslError: snapshot.sslError, 568 resolverURLString: snapshot.resolverURLString,
569 httpHeadersError: snapshot.httpHeadersError, 569 totalLookupDurationMs: snapshot.totalLookupDurationMs
570 reachabilityError: snapshot.reachabilityError, 570 ),
571 ipGeolocationError: snapshot.ipGeolocationError, 571 summary: HistoryEntry.Summary(
572 emailSecurityError: snapshot.emailSecurityError, 572 primaryIP: Self.primaryIPAddress(from: snapshot),
573 ownershipError: snapshot.ownershipError, 573 finalRedirectURL: snapshot.redirectChain.last?.url,
574 ownershipHistoryError: snapshot.ownershipHistoryError, 574 tlsStatusSummary: Self.tlsSummary(from: snapshot),
575 ptrError: snapshot.ptrError, 575 emailSecuritySummary: Self.emailSummary(from: snapshot),
576 redirectChainError: snapshot.redirectChainError, 576 httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError,
577 subdomainsError: snapshot.subdomainsError, 577 changeSummary: changeSummary
578 extendedSubdomainsError: snapshot.extendedSubdomainsError, 578 ),
579 dnsHistoryError: snapshot.dnsHistoryError, 579 failures: HistoryEntry.Failures(
580 domainPricingError: snapshot.domainPricingError, 580 errorDetails: snapshot.errorDetails,
581 reputationError: snapshot.reputationError, 581 sslError: snapshot.sslError,
582 portScanError: snapshot.portScanError 582 httpHeadersError: snapshot.httpHeadersError,
583 reachabilityError: snapshot.reachabilityError,
584 ipGeolocationError: snapshot.ipGeolocationError,
585 emailSecurityError: snapshot.emailSecurityError,
586 ownershipError: snapshot.ownershipError,
587 ownershipHistoryError: snapshot.ownershipHistoryError,
588 ptrError: snapshot.ptrError,
589 redirectChainError: snapshot.redirectChainError,
590 subdomainsError: snapshot.subdomainsError,
591 extendedSubdomainsError: snapshot.extendedSubdomainsError,
592 dnsHistoryError: snapshot.dnsHistoryError,
593 domainPricingError: snapshot.domainPricingError,
594 reputationError: snapshot.reputationError,
595 portScanError: snapshot.portScanError
596 )
583 ) 597 )
584 598
585 history.insert(entry, at: 0) 599 history.insert(entry, at: 0)
DomainDig/DomainViewModel.swift +86 −72
@@ -1936,78 +1936,92 @@ final class DomainViewModel {
1936 } 1936 }
1937 1937
1938 let entry = HistoryEntry( 1938 let entry = HistoryEntry(
1939 domain: snapshot.domain, 1939 identity: HistoryEntry.Identity(
1940 timestamp: snapshot.timestamp, 1940 domain: snapshot.domain,
1941 trackedDomainID: trackedDomainID, 1941 timestamp: snapshot.timestamp,
1942 note: currentHistoryEntry?.note, 1942 trackedDomainID: trackedDomainID,
1943 dnsSections: snapshot.dnsSections, 1943 note: currentHistoryEntry?.note
1944 sslInfo: snapshot.sslInfo, 1944 ),
1945 httpHeaders: snapshot.httpHeaders, 1945 inspection: HistoryEntry.Inspection(
1946 reachabilityResults: snapshot.reachabilityResults, 1946 dnsSections: snapshot.dnsSections,
1947 ipGeolocation: snapshot.ipGeolocation, 1947 sslInfo: snapshot.sslInfo,
1948 emailSecurity: snapshot.emailSecurity, 1948 httpHeaders: snapshot.httpHeaders,
1949 mtaSts: snapshot.emailSecurity?.mtaSts, 1949 reachabilityResults: snapshot.reachabilityResults,
1950 ownership: snapshot.ownership, 1950 ipGeolocation: snapshot.ipGeolocation,
1951 ownershipHistory: snapshot.ownershipHistory, 1951 emailSecurity: snapshot.emailSecurity,
1952 inferredProvider: intelligence.inferredProvider, 1952 mtaSts: snapshot.emailSecurity?.mtaSts,
1953 priorProviders: intelligence.priorProviders, 1953 ptrRecord: snapshot.ptrRecord,
1954 domainClassification: intelligence.domainClassification, 1954 redirectChain: snapshot.redirectChain,
1955 ownershipTransitions: intelligence.ownershipTransitions, 1955 subdomains: snapshot.subdomains,
1956 hostingTransitions: intelligence.hostingTransitions, 1956 extendedSubdomains: snapshot.extendedSubdomains,
1957 subdomainHistory: intelligence.subdomainHistory, 1957 dnsHistory: snapshot.dnsHistory,
1958 riskSignals: intelligence.riskSignals, 1958 portScanResults: snapshot.portScanResults,
1959 intelligenceTimeline: intelligence.timelineEvents, 1959 hstsPreloaded: snapshot.hstsPreloaded,
1960 ptrRecord: snapshot.ptrRecord, 1960 availabilityResult: snapshot.availabilityResult,
1961 redirectChain: snapshot.redirectChain, 1961 suggestions: snapshot.suggestions
1962 subdomains: snapshot.subdomains, 1962 ),
1963 extendedSubdomains: snapshot.extendedSubdomains, 1963 registration: HistoryEntry.Registration(
1964 dnsHistory: snapshot.dnsHistory, 1964 ownership: snapshot.ownership,
1965 domainPricing: snapshot.domainPricing, 1965 ownershipHistory: snapshot.ownershipHistory,
1966 reputation: snapshot.reputation, 1966 inferredProvider: intelligence.inferredProvider,
1967 portScanResults: snapshot.portScanResults, 1967 priorProviders: intelligence.priorProviders,
1968 hstsPreloaded: snapshot.hstsPreloaded, 1968 domainClassification: intelligence.domainClassification,
1969 availabilityResult: snapshot.availabilityResult, 1969 ownershipTransitions: intelligence.ownershipTransitions,
1970 suggestions: snapshot.suggestions, 1970 hostingTransitions: intelligence.hostingTransitions,
1971 appVersion: snapshot.appVersion, 1971 domainPricing: snapshot.domainPricing
1972 resultSource: snapshot.resultSource, 1972 ),
1973 dataSources: snapshot.dataSources, 1973 intelligence: HistoryEntry.Intelligence(
1974 provenanceBySection: snapshot.provenanceBySection, 1974 subdomainHistory: intelligence.subdomainHistory,
1975 availabilityConfidence: snapshot.availabilityConfidence, 1975 riskSignals: intelligence.riskSignals,
1976 ownershipConfidence: snapshot.ownershipConfidence, 1976 intelligenceTimeline: intelligence.timelineEvents,
1977 subdomainConfidence: snapshot.subdomainConfidence, 1977 reputation: snapshot.reputation
1978 emailSecurityConfidence: snapshot.emailSecurityConfidence, 1978 ),
1979 geolocationConfidence: snapshot.geolocationConfidence, 1979 provenance: HistoryEntry.Provenance(
1980 errorDetails: snapshot.errorDetails, 1980 appVersion: snapshot.appVersion,
1981 isPartialSnapshot: snapshot.isPartialSnapshot, 1981 resultSource: snapshot.resultSource,
1982 validationIssues: snapshot.validationIssues, 1982 dataSources: snapshot.dataSources,
1983 resolverDisplayName: snapshot.resolverDisplayName, 1983 provenanceBySection: snapshot.provenanceBySection,
1984 resolverURLString: snapshot.resolverURLString, 1984 availabilityConfidence: snapshot.availabilityConfidence,
1985 totalLookupDurationMs: snapshot.totalLookupDurationMs, 1985 ownershipConfidence: snapshot.ownershipConfidence,
1986 primaryIP: Self.primaryIPAddress(from: snapshot), 1986 subdomainConfidence: snapshot.subdomainConfidence,
1987 finalRedirectURL: Self.finalRedirectTarget(from: snapshot), 1987 emailSecurityConfidence: snapshot.emailSecurityConfidence,
1988 tlsStatusSummary: Self.httpsSummary(from: snapshot), 1988 geolocationConfidence: snapshot.geolocationConfidence,
1989 emailSecuritySummary: Self.emailSummary(from: snapshot), 1989 isPartialSnapshot: snapshot.isPartialSnapshot,
1990 httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError, 1990 validationIssues: snapshot.validationIssues,
1991 changeSummary: changeSummary, 1991 resolverDisplayName: snapshot.resolverDisplayName,
1992 snapshotIndex: nextSnapshotIndex, 1992 resolverURLString: snapshot.resolverURLString,
1993 previousSnapshotID: previousSnapshotID, 1993 totalLookupDurationMs: snapshot.totalLookupDurationMs
1994 changeCount: domainDiff?.changeCount ?? changeSummary?.changedSections.count ?? 0, 1994 ),
1995 severitySummary: changeSummary?.severity, 1995 summary: HistoryEntry.Summary(
1996 sslError: snapshot.sslError, 1996 primaryIP: Self.primaryIPAddress(from: snapshot),
1997 httpHeadersError: snapshot.httpHeadersError, 1997 finalRedirectURL: Self.finalRedirectTarget(from: snapshot),
1998 reachabilityError: snapshot.reachabilityError, 1998 tlsStatusSummary: Self.httpsSummary(from: snapshot),
1999 ipGeolocationError: snapshot.ipGeolocationError, 1999 emailSecuritySummary: Self.emailSummary(from: snapshot),
2000 emailSecurityError: snapshot.emailSecurityError, 2000 httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError,
2001 ownershipError: snapshot.ownershipError, 2001 changeSummary: changeSummary,
2002 ownershipHistoryError: snapshot.ownershipHistoryError, 2002 snapshotIndex: nextSnapshotIndex,
2003 ptrError: snapshot.ptrError, 2003 previousSnapshotID: previousSnapshotID,
2004 redirectChainError: snapshot.redirectChainError, 2004 changeCount: domainDiff?.changeCount ?? changeSummary?.changedSections.count ?? 0,
2005 subdomainsError: snapshot.subdomainsError, 2005 severitySummary: changeSummary?.severity
2006 extendedSubdomainsError: snapshot.extendedSubdomainsError, 2006 ),
2007 dnsHistoryError: snapshot.dnsHistoryError, 2007 failures: HistoryEntry.Failures(
2008 domainPricingError: snapshot.domainPricingError, 2008 errorDetails: snapshot.errorDetails,
2009 reputationError: snapshot.reputationError, 2009 sslError: snapshot.sslError,
2010 portScanError: snapshot.portScanError 2010 httpHeadersError: snapshot.httpHeadersError,
2011 reachabilityError: snapshot.reachabilityError,
2012 ipGeolocationError: snapshot.ipGeolocationError,
2013 emailSecurityError: snapshot.emailSecurityError,
2014 ownershipError: snapshot.ownershipError,
2015 ownershipHistoryError: snapshot.ownershipHistoryError,
2016 ptrError: snapshot.ptrError,
2017 redirectChainError: snapshot.redirectChainError,
2018 subdomainsError: snapshot.subdomainsError,
2019 extendedSubdomainsError: snapshot.extendedSubdomainsError,
2020 dnsHistoryError: snapshot.dnsHistoryError,
2021 domainPricingError: snapshot.domainPricingError,
2022 reputationError: snapshot.reputationError,
2023 portScanError: snapshot.portScanError
2024 )
2011 ) 2025 )
2012 2026
2013 if updateCurrentState { 2027 if updateCurrentState {
DomainDig/Models.swift +190 −107
@@ -2399,113 +2399,196 @@ struct HistoryEntry: Identifiable, Codable {
2399 var reputationError: String? 2399 var reputationError: String?
2400 var portScanError: String? 2400 var portScanError: String?
2401 2401
2402 init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, note: String? = nil, dnsSections: [DNSSection], 2402 // A full inspection produces 73 stored properties, and passing them as one
2403 sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader], 2403 // flat argument list made a 72-parameter initializer no call site could read
2404 reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, 2404 // (SonarCloud swift:S107). They are grouped below by what they describe.
2405 emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ownership: DomainOwnership? = nil, 2405 //
2406 ownershipHistory: [DomainOwnershipHistoryEvent] = [], 2406 // The stored properties stay flat, deliberately. This type is persisted to
2407 inferredProvider: InferredProviderFingerprint? = nil, priorProviders: [String] = [], 2407 // UserDefaults and read back out of backup files, and both decode paths drop
2408 domainClassification: DomainClassificationSummary? = nil, 2408 // entries that will not parse rather than raising — so nesting a value would
2409 ownershipTransitions: [OwnershipTransitionEvent] = [], 2409 // change the encoded shape and silently discard history written by an older
2410 hostingTransitions: [HostingTransitionEvent] = [], 2410 // build. These groups exist at the call boundary only; the JSON is unchanged,
2411 subdomainHistory: [SubdomainHistoryEntry] = [], 2411 // which HistoryEntryCodableTests pins.
2412 riskSignals: [IntelligenceRiskSignal] = [], 2412
2413 intelligenceTimeline: [IntelligenceTimelineEvent] = [], 2413 /// What was inspected, and when.
2414 ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [], 2414 struct Identity {
2415 extendedSubdomains: [DiscoveredSubdomain] = [], dnsHistory: [DNSHistoryEvent] = [], 2415 var domain: String
2416 domainPricing: DomainPricingInsight? = nil, 2416 var timestamp: Date
2417 reputation: DomainReputationResult? = nil, 2417 var trackedDomainID: UUID? = nil
2418 portScanResults: [PortScanResult] = [], 2418 var note: String? = nil
2419 hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil, 2419 }
2420 suggestions: [DomainSuggestionResult] = [], appVersion: String = "2.7.0", 2420
2421 resultSource: LookupResultSource = .snapshot, dataSources: [String] = [], 2421 /// The live probe results for this snapshot.
2422 provenanceBySection: [LookupSectionKind: SectionProvenance] = [:], 2422 struct Inspection {
2423 availabilityConfidence: ConfidenceLevel? = nil, ownershipConfidence: ConfidenceLevel? = nil, 2423 var dnsSections: [DNSSection]
2424 subdomainConfidence: ConfidenceLevel? = nil, emailSecurityConfidence: ConfidenceLevel? = nil, 2424 var sslInfo: SSLCertificateInfo?
2425 geolocationConfidence: ConfidenceLevel? = nil, 2425 var httpHeaders: [HTTPHeader]
2426 errorDetails: [LookupSectionKind: InspectionFailure] = [:], isPartialSnapshot: Bool = false, 2426 var reachabilityResults: [PortReachability]
2427 validationIssues: [String] = [], resolverDisplayName: String, resolverURLString: String, 2427 var ipGeolocation: IPGeolocation?
2428 totalLookupDurationMs: Int? = nil, primaryIP: String? = nil, finalRedirectURL: String? = nil, 2428 var emailSecurity: EmailSecurityResult? = nil
2429 tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil, 2429 var mtaSts: MTASTSResult? = nil
2430 changeSummary: DomainChangeSummary? = nil, snapshotIndex: Int? = nil, previousSnapshotID: UUID? = nil, 2430 var ptrRecord: String? = nil
2431 changeCount: Int = 0, severitySummary: ChangeSeverity? = nil, sslError: String? = nil, httpHeadersError: String? = nil, 2431 var redirectChain: [RedirectHop] = []
2432 reachabilityError: String? = nil, ipGeolocationError: String? = nil, 2432 var subdomains: [DiscoveredSubdomain] = []
2433 emailSecurityError: String? = nil, ownershipError: String? = nil, ownershipHistoryError: String? = nil, 2433 var extendedSubdomains: [DiscoveredSubdomain] = []
2434 ptrError: String? = nil, redirectChainError: String? = nil, subdomainsError: String? = nil, 2434 var dnsHistory: [DNSHistoryEvent] = []
2435 extendedSubdomainsError: String? = nil, dnsHistoryError: String? = nil, 2435 var portScanResults: [PortScanResult] = []
2436 domainPricingError: String? = nil, reputationError: String? = nil, portScanError: String? = nil) { 2436 var hstsPreloaded: Bool? = nil
2437 self.domain = domain 2437 var availabilityResult: DomainAvailabilityResult? = nil
2438 self.timestamp = timestamp 2438 var suggestions: [DomainSuggestionResult] = []
2439 self.trackedDomainID = trackedDomainID 2439 }
2440 self.note = note 2440
2441 self.dnsSections = dnsSections 2441 /// Who owns the domain and where it is hosted.
2442 self.sslInfo = sslInfo 2442 struct Registration {
2443 self.httpHeaders = httpHeaders 2443 var ownership: DomainOwnership? = nil
2444 self.reachabilityResults = reachabilityResults 2444 var ownershipHistory: [DomainOwnershipHistoryEvent] = []
2445 self.ipGeolocation = ipGeolocation 2445 var inferredProvider: InferredProviderFingerprint? = nil
2446 self.emailSecurity = emailSecurity 2446 var priorProviders: [String] = []
2447 self.mtaSts = mtaSts ?? emailSecurity?.mtaSts 2447 var domainClassification: DomainClassificationSummary? = nil
2448 self.ownership = ownership 2448 var ownershipTransitions: [OwnershipTransitionEvent] = []
2449 self.ownershipHistory = ownershipHistory 2449 var hostingTransitions: [HostingTransitionEvent] = []
2450 self.inferredProvider = inferredProvider 2450 var domainPricing: DomainPricingInsight? = nil
2451 self.priorProviders = priorProviders 2451 }
2452 self.domainClassification = domainClassification 2452
2453 self.ownershipTransitions = ownershipTransitions 2453 /// Derived signals rather than direct observations.
2454 self.hostingTransitions = hostingTransitions 2454 struct Intelligence {
2455 self.subdomainHistory = subdomainHistory 2455 var subdomainHistory: [SubdomainHistoryEntry] = []
2456 self.riskSignals = riskSignals 2456 var riskSignals: [IntelligenceRiskSignal] = []
2457 self.intelligenceTimeline = intelligenceTimeline 2457 var intelligenceTimeline: [IntelligenceTimelineEvent] = []
2458 self.ptrRecord = ptrRecord 2458 var reputation: DomainReputationResult? = nil
2459 self.redirectChain = redirectChain 2459 }
2460 self.subdomains = subdomains 2460
2461 self.extendedSubdomains = extendedSubdomains 2461 /// Where the data came from, and how much to trust it.
2462 self.dnsHistory = dnsHistory 2462 struct Provenance {
2463 self.domainPricing = domainPricing 2463 var appVersion: String = "2.7.0"
2464 self.reputation = reputation 2464 var resultSource: LookupResultSource = .snapshot
2465 self.portScanResults = portScanResults 2465 var dataSources: [String] = []
2466 self.hstsPreloaded = hstsPreloaded 2466 var provenanceBySection: [LookupSectionKind: SectionProvenance] = [:]
2467 self.availabilityResult = availabilityResult 2467 var availabilityConfidence: ConfidenceLevel? = nil
2468 self.suggestions = suggestions 2468 var ownershipConfidence: ConfidenceLevel? = nil
2469 self.appVersion = appVersion 2469 var subdomainConfidence: ConfidenceLevel? = nil
2470 self.resultSource = resultSource 2470 var emailSecurityConfidence: ConfidenceLevel? = nil
2471 self.dataSources = dataSources 2471 var geolocationConfidence: ConfidenceLevel? = nil
2472 self.provenanceBySection = provenanceBySection 2472 var isPartialSnapshot: Bool = false
2473 self.availabilityConfidence = availabilityConfidence 2473 var validationIssues: [String] = []
2474 self.ownershipConfidence = ownershipConfidence 2474 var resolverDisplayName: String
2475 self.subdomainConfidence = subdomainConfidence 2475 var resolverURLString: String
2476 self.emailSecurityConfidence = emailSecurityConfidence 2476 var totalLookupDurationMs: Int? = nil
2477 self.geolocationConfidence = geolocationConfidence 2477 }
2478 self.errorDetails = errorDetails 2478
2479 self.isPartialSnapshot = isPartialSnapshot 2479 /// Precomputed display values and change tracking.
2480 self.validationIssues = validationIssues 2480 struct Summary {
2481 self.resolverDisplayName = resolverDisplayName 2481 var primaryIP: String? = nil
2482 self.resolverURLString = resolverURLString 2482 var finalRedirectURL: String? = nil
2483 self.totalLookupDurationMs = totalLookupDurationMs 2483 var tlsStatusSummary: String? = nil
2484 self.primaryIP = primaryIP 2484 var emailSecuritySummary: String? = nil
2485 self.finalRedirectURL = finalRedirectURL 2485 var httpGradeSummary: String? = nil
2486 self.tlsStatusSummary = tlsStatusSummary 2486 var changeSummary: DomainChangeSummary? = nil
2487 self.emailSecuritySummary = emailSecuritySummary 2487 var snapshotIndex: Int? = nil
2488 self.httpGradeSummary = httpGradeSummary 2488 var previousSnapshotID: UUID? = nil
2489 self.changeSummary = changeSummary 2489 var changeCount: Int = 0
2490 self.snapshotIndex = snapshotIndex 2490 var severitySummary: ChangeSeverity? = nil
2491 self.previousSnapshotID = previousSnapshotID 2491 }
2492 self.changeCount = changeCount 2492
2493 self.severitySummary = severitySummary 2493 /// Per-section failures: a snapshot records what it could not collect.
2494 self.sslError = sslError 2494 struct Failures {
2495 self.httpHeadersError = httpHeadersError 2495 var errorDetails: [LookupSectionKind: InspectionFailure] = [:]
2496 self.reachabilityError = reachabilityError 2496 var sslError: String? = nil
2497 self.ipGeolocationError = ipGeolocationError 2497 var httpHeadersError: String? = nil
2498 self.emailSecurityError = emailSecurityError 2498 var reachabilityError: String? = nil
2499 self.ownershipError = ownershipError 2499 var ipGeolocationError: String? = nil
2500 self.ownershipHistoryError = ownershipHistoryError 2500 var emailSecurityError: String? = nil
2501 self.ptrError = ptrError 2501 var ownershipError: String? = nil
2502 self.redirectChainError = redirectChainError 2502 var ownershipHistoryError: String? = nil
2503 self.subdomainsError = subdomainsError 2503 var ptrError: String? = nil
2504 self.extendedSubdomainsError = extendedSubdomainsError 2504 var redirectChainError: String? = nil
2505 self.dnsHistoryError = dnsHistoryError 2505 var subdomainsError: String? = nil
2506 self.domainPricingError = domainPricingError 2506 var extendedSubdomainsError: String? = nil
2507 self.reputationError = reputationError 2507 var dnsHistoryError: String? = nil
2508 self.portScanError = portScanError 2508 var domainPricingError: String? = nil
2509 var reputationError: String? = nil
2510 var portScanError: String? = nil
2511 }
2512
2513 init(identity: Identity,
2514 inspection: Inspection,
2515 registration: Registration = Registration(),
2516 intelligence: Intelligence = Intelligence(),
2517 provenance: Provenance,
2518 summary: Summary = Summary(),
2519 failures: Failures = Failures()) {
2520 self.domain = identity.domain
2521 self.timestamp = identity.timestamp
2522 self.trackedDomainID = identity.trackedDomainID
2523 self.note = identity.note
2524 self.dnsSections = inspection.dnsSections
2525 self.sslInfo = inspection.sslInfo
2526 self.httpHeaders = inspection.httpHeaders
2527 self.reachabilityResults = inspection.reachabilityResults
2528 self.ipGeolocation = inspection.ipGeolocation
2529 self.emailSecurity = inspection.emailSecurity
2530 self.mtaSts = inspection.mtaSts ?? inspection.emailSecurity?.mtaSts
2531 self.ptrRecord = inspection.ptrRecord
2532 self.redirectChain = inspection.redirectChain
2533 self.subdomains = inspection.subdomains
2534 self.extendedSubdomains = inspection.extendedSubdomains
2535 self.dnsHistory = inspection.dnsHistory
2536 self.portScanResults = inspection.portScanResults
2537 self.hstsPreloaded = inspection.hstsPreloaded
2538 self.availabilityResult = inspection.availabilityResult
2539 self.suggestions = inspection.suggestions
2540 self.ownership = registration.ownership
2541 self.ownershipHistory = registration.ownershipHistory
2542 self.inferredProvider = registration.inferredProvider
2543 self.priorProviders = registration.priorProviders
2544 self.domainClassification = registration.domainClassification
2545 self.ownershipTransitions = registration.ownershipTransitions
2546 self.hostingTransitions = registration.hostingTransitions
2547 self.domainPricing = registration.domainPricing
2548 self.subdomainHistory = intelligence.subdomainHistory
2549 self.riskSignals = intelligence.riskSignals
2550 self.intelligenceTimeline = intelligence.intelligenceTimeline
2551 self.reputation = intelligence.reputation
2552 self.appVersion = provenance.appVersion
2553 self.resultSource = provenance.resultSource
2554 self.dataSources = provenance.dataSources
2555 self.provenanceBySection = provenance.provenanceBySection
2556 self.availabilityConfidence = provenance.availabilityConfidence
2557 self.ownershipConfidence = provenance.ownershipConfidence
2558 self.subdomainConfidence = provenance.subdomainConfidence
2559 self.emailSecurityConfidence = provenance.emailSecurityConfidence
2560 self.geolocationConfidence = provenance.geolocationConfidence
2561 self.isPartialSnapshot = provenance.isPartialSnapshot
2562 self.validationIssues = provenance.validationIssues
2563 self.resolverDisplayName = provenance.resolverDisplayName
2564 self.resolverURLString = provenance.resolverURLString
2565 self.totalLookupDurationMs = provenance.totalLookupDurationMs
2566 self.primaryIP = summary.primaryIP
2567 self.finalRedirectURL = summary.finalRedirectURL
2568 self.tlsStatusSummary = summary.tlsStatusSummary
2569 self.emailSecuritySummary = summary.emailSecuritySummary
2570 self.httpGradeSummary = summary.httpGradeSummary
2571 self.changeSummary = summary.changeSummary
2572 self.snapshotIndex = summary.snapshotIndex
2573 self.previousSnapshotID = summary.previousSnapshotID
2574 self.changeCount = summary.changeCount
2575 self.severitySummary = summary.severitySummary
2576 self.errorDetails = failures.errorDetails
2577 self.sslError = failures.sslError
2578 self.httpHeadersError = failures.httpHeadersError
2579 self.reachabilityError = failures.reachabilityError
2580 self.ipGeolocationError = failures.ipGeolocationError
2581 self.emailSecurityError = failures.emailSecurityError
2582 self.ownershipError = failures.ownershipError
2583 self.ownershipHistoryError = failures.ownershipHistoryError
2584 self.ptrError = failures.ptrError
2585 self.redirectChainError = failures.redirectChainError
2586 self.subdomainsError = failures.subdomainsError
2587 self.extendedSubdomainsError = failures.extendedSubdomainsError
2588 self.dnsHistoryError = failures.dnsHistoryError
2589 self.domainPricingError = failures.domainPricingError
2590 self.reputationError = failures.reputationError
2591 self.portScanError = failures.portScanError
2509 } 2592 }
2510 2593
2511 init(from decoder: Decoder) throws { 2594 init(from decoder: Decoder) throws {
DomainDigTests/HistoryEntryCodableTests.swift added +129
@@ -0,0 +1,129 @@
1import XCTest
2@testable import DomainDig
3
4/// Characterization tests pinning `HistoryEntry`'s encoded shape.
5///
6/// This type is not merely an in-memory model: `DomainDataPortabilityService`
7/// persists it to `UserDefaults` as JSON and reads the same shape back out of
8/// backup files. Both decode paths swallow failures — `loadHistoryEntries` uses
9/// `try?` and drops anything that will not decode — so a change to the encoded
10/// keys does not raise, it silently discards a user's saved history.
11///
12/// These tests exist so that any such change fails here first, loudly, instead
13/// of in someone's app.
14final class HistoryEntryCodableTests: XCTestCase {
15 /// Every key `HistoryEntry` is expected to encode. Adding a stored property
16 /// is a format change: extend this list deliberately, and only once you have
17 /// decided what happens to history written by an older build.
18 private static let expectedKeys: Set<String> = [
19 "id", "domain", "timestamp", "trackedDomainID", "note", "dnsSections",
20 "sslInfo", "httpHeaders", "reachabilityResults", "ipGeolocation",
21 "emailSecurity", "mtaSts", "ownership", "ownershipHistory",
22 "inferredProvider", "priorProviders", "domainClassification",
23 "ownershipTransitions", "hostingTransitions", "subdomainHistory",
24 "riskSignals", "intelligenceTimeline", "ptrRecord", "redirectChain",
25 "subdomains", "extendedSubdomains", "dnsHistory", "domainPricing",
26 "reputation", "portScanResults", "hstsPreloaded", "availabilityResult",
27 "suggestions", "appVersion", "resultSource", "dataSources",
28 "provenanceBySection", "availabilityConfidence", "ownershipConfidence",
29 "subdomainConfidence", "emailSecurityConfidence", "geolocationConfidence",
30 "errorDetails", "isPartialSnapshot", "validationIssues",
31 "resolverDisplayName", "resolverURLString", "totalLookupDurationMs",
32 "primaryIP", "finalRedirectURL", "tlsStatusSummary",
33 "emailSecuritySummary", "httpGradeSummary", "changeSummary",
34 "snapshotIndex", "previousSnapshotID", "changeCount", "severitySummary",
35 "sslError", "httpHeadersError", "reachabilityError", "ipGeolocationError",
36 "emailSecurityError", "ownershipError", "ownershipHistoryError",
37 "ptrError", "redirectChainError", "subdomainsError",
38 "extendedSubdomainsError", "dnsHistoryError", "domainPricingError",
39 "reputationError", "portScanError",
40 ]
41
42 private func makeEntry() -> HistoryEntry {
43 HistoryEntry(
44 identity: .init(
45 domain: "example.com",
46 timestamp: Date(timeIntervalSince1970: 1_700_000_000)
47 ),
48 inspection: .init(
49 dnsSections: [],
50 sslInfo: nil,
51 httpHeaders: [],
52 reachabilityResults: [],
53 ipGeolocation: nil
54 ),
55 provenance: .init(
56 resolverDisplayName: "Test Resolver",
57 resolverURLString: "https://resolver.example/dns-query"
58 )
59 )
60 }
61
62 private func encoder() -> JSONEncoder {
63 let encoder = JSONEncoder()
64 encoder.outputFormatting = .sortedKeys
65 return encoder
66 }
67
68 /// The set of top-level keys is the persisted contract. Optionals that are
69 /// nil are omitted by the synthesized encoder, so this asserts containment
70 /// rather than equality — no key may appear that is not accounted for.
71 func testEncodedKeysAreAllAccountedFor() throws {
72 let data = try encoder().encode(makeEntry())
73 let object = try XCTUnwrap(
74 JSONSerialization.jsonObject(with: data) as? [String: Any]
75 )
76
77 let unexpected = Set(object.keys).subtracting(Self.expectedKeys)
78 XCTAssertTrue(
79 unexpected.isEmpty,
80 "HistoryEntry encoded keys not in the pinned set: \(unexpected.sorted()). "
81 + "This changes the persisted format; older history will not decode."
82 )
83 }
84
85 /// A populated entry must survive encode → decode → encode unchanged. This
86 /// is the guard that a refactor which regroups the initializer has not also
87 /// moved a value into a different place in the JSON.
88 func testRoundTripIsStable() throws {
89 let first = try encoder().encode(makeEntry())
90 let decoded = try JSONDecoder().decode(HistoryEntry.self, from: first)
91 let second = try encoder().encode(decoded)
92
93 XCTAssertEqual(
94 first, second,
95 "HistoryEntry did not survive a JSON round trip unchanged"
96 )
97 }
98
99 /// The values a caller supplies must land under the keys the persisted format
100 /// already uses, not merely somewhere in the document.
101 func testRequiredValuesEncodeAtTheTopLevel() throws {
102 let data = try encoder().encode(makeEntry())
103 let object = try XCTUnwrap(
104 JSONSerialization.jsonObject(with: data) as? [String: Any]
105 )
106
107 XCTAssertEqual(object["domain"] as? String, "example.com")
108 XCTAssertEqual(object["resolverDisplayName"] as? String, "Test Resolver")
109 XCTAssertEqual(
110 object["resolverURLString"] as? String,
111 "https://resolver.example/dns-query"
112 )
113 }
114
115 /// What `loadHistoryEntries` actually does with a stored blob, end to end:
116 /// anything that fails to decode is dropped without error, so this pins that
117 /// a current-format entry survives the real read path.
118 func testSurvivesTheRealPersistencePath() throws {
119 let suite = "DomainDigTests.historyEntryCodable"
120 let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
121 defer { defaults.removePersistentDomain(forName: suite) }
122
123 DomainDataPortabilityService.saveHistoryEntries([makeEntry()], defaults: defaults)
124 let loaded = DomainDataPortabilityService.loadHistoryEntries(defaults: defaults)
125
126 XCTAssertEqual(loaded.count, 1, "entry was silently dropped by the load path")
127 XCTAssertEqual(loaded.first?.domain, "example.com")
128 }
129}