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 @@
1212 47CD3BB1AE143733A73E0E5B /* DomainReportExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D7C7D97A4006831572F468 /* DomainReportExporterTests.swift */; };
1313 4F96CEB875EC3501E784CE39 /* DataMigrationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435AFB3F99D579D3D7B58279 /* DataMigrationServiceTests.swift */; };
1414 81359F63C7A23454B8FA0141 /* DomainReportBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E82320955416797CFE59464A /* DomainReportBuilderTests.swift */; };
15 A1B2C3D40000000000000102 /* HistoryEntryCodableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000000000101 /* HistoryEntryCodableTests.swift */; };
1516 8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */; };
1617 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; };
1718 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; };
@@ -98,6 +99,7 @@
9899 CC948A02EC0184228BC4630E /* DomainDataPortabilityServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DomainDataPortabilityServiceTests.swift; sourceTree = "<group>"; };
99100 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; };
100101 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>"; };
101103/* End PBXFileReference section */
102104
103105/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -235,6 +237,7 @@
235237 0D85A44C5F315A1644AC9073 /* LocalAPIContractTests.swift */,
236238 435AFB3F99D579D3D7B58279 /* DataMigrationServiceTests.swift */,
237239 499B99A1866999A38AC475F5 /* AppInfoTests.swift */,
240 A1B2C3D40000000000000101 /* HistoryEntryCodableTests.swift */,
238241 );
239242 name = DomainDigTests;
240243 path = DomainDigTests;
@@ -489,6 +492,7 @@
489492 files = (
490493 E959C4D24DAAB3CB80D854B2 /* DiffServiceTests.swift in Sources */,
491494 81359F63C7A23454B8FA0141 /* DomainReportBuilderTests.swift in Sources */,
495 A1B2C3D40000000000000102 /* HistoryEntryCodableTests.swift in Sources */,
492496 47CD3BB1AE143733A73E0E5B /* DomainReportExporterTests.swift in Sources */,
493497 C7CA9E02B0DC2708DE7A8563 /* DomainDataPortabilityServiceTests.swift in Sources */,
494498 A5AF921BC1C2E6E940CC05DC /* SnapshotFixture.swift in Sources */,
DomainDig/DNSLookupService.swift +1 −1
@@ -364,7 +364,7 @@ struct DNSLookupService {
364364 answers.append(.init(
365365 name: name,
366366 type: type,
367 TTL: ttl,
367 ttl: ttl,
368368 data: parsedValue
369369 ))
370370 }
DomainDig/DomainMonitoringService.swift +74 −60
@@ -520,66 +520,80 @@ final class DomainMonitoringService {
520520 }
521521
522522 let entry = HistoryEntry(
523 domain: snapshot.domain,
524 timestamp: snapshot.timestamp,
525 trackedDomainID: trackedDomainID,
526 note: trackedDomains.first(where: { $0.id == trackedDomainID })?.note,
527 dnsSections: snapshot.dnsSections,
528 sslInfo: snapshot.sslInfo,
529 httpHeaders: snapshot.httpHeaders,
530 reachabilityResults: snapshot.reachabilityResults,
531 ipGeolocation: snapshot.ipGeolocation,
532 emailSecurity: snapshot.emailSecurity,
533 mtaSts: snapshot.emailSecurity?.mtaSts,
534 ownership: snapshot.ownership,
535 ownershipHistory: snapshot.ownershipHistory,
536 ptrRecord: snapshot.ptrRecord,
537 redirectChain: snapshot.redirectChain,
538 subdomains: snapshot.subdomains,
539 extendedSubdomains: snapshot.extendedSubdomains,
540 dnsHistory: snapshot.dnsHistory,
541 domainPricing: snapshot.domainPricing,
542 reputation: snapshot.reputation,
543 portScanResults: snapshot.portScanResults,
544 hstsPreloaded: snapshot.hstsPreloaded,
545 availabilityResult: snapshot.availabilityResult,
546 suggestions: snapshot.suggestions,
547 appVersion: snapshot.appVersion,
548 resultSource: snapshot.resultSource,
549 dataSources: snapshot.dataSources,
550 provenanceBySection: snapshot.provenanceBySection,
551 availabilityConfidence: snapshot.availabilityConfidence,
552 ownershipConfidence: snapshot.ownershipConfidence,
553 subdomainConfidence: snapshot.subdomainConfidence,
554 emailSecurityConfidence: snapshot.emailSecurityConfidence,
555 geolocationConfidence: snapshot.geolocationConfidence,
556 errorDetails: snapshot.errorDetails,
557 isPartialSnapshot: snapshot.isPartialSnapshot,
558 validationIssues: snapshot.validationIssues,
559 resolverDisplayName: snapshot.resolverDisplayName,
560 resolverURLString: snapshot.resolverURLString,
561 totalLookupDurationMs: snapshot.totalLookupDurationMs,
562 primaryIP: Self.primaryIPAddress(from: snapshot),
563 finalRedirectURL: snapshot.redirectChain.last?.url,
564 tlsStatusSummary: Self.tlsSummary(from: snapshot),
565 emailSecuritySummary: Self.emailSummary(from: snapshot),
566 httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError,
567 changeSummary: changeSummary,
568 sslError: snapshot.sslError,
569 httpHeadersError: snapshot.httpHeadersError,
570 reachabilityError: snapshot.reachabilityError,
571 ipGeolocationError: snapshot.ipGeolocationError,
572 emailSecurityError: snapshot.emailSecurityError,
573 ownershipError: snapshot.ownershipError,
574 ownershipHistoryError: snapshot.ownershipHistoryError,
575 ptrError: snapshot.ptrError,
576 redirectChainError: snapshot.redirectChainError,
577 subdomainsError: snapshot.subdomainsError,
578 extendedSubdomainsError: snapshot.extendedSubdomainsError,
579 dnsHistoryError: snapshot.dnsHistoryError,
580 domainPricingError: snapshot.domainPricingError,
581 reputationError: snapshot.reputationError,
582 portScanError: snapshot.portScanError
523 identity: HistoryEntry.Identity(
524 domain: snapshot.domain,
525 timestamp: snapshot.timestamp,
526 trackedDomainID: trackedDomainID,
527 note: trackedDomains.first(where: { $0.id == trackedDomainID })?.note
528 ),
529 inspection: HistoryEntry.Inspection(
530 dnsSections: snapshot.dnsSections,
531 sslInfo: snapshot.sslInfo,
532 httpHeaders: snapshot.httpHeaders,
533 reachabilityResults: snapshot.reachabilityResults,
534 ipGeolocation: snapshot.ipGeolocation,
535 emailSecurity: snapshot.emailSecurity,
536 mtaSts: snapshot.emailSecurity?.mtaSts,
537 ptrRecord: snapshot.ptrRecord,
538 redirectChain: snapshot.redirectChain,
539 subdomains: snapshot.subdomains,
540 extendedSubdomains: snapshot.extendedSubdomains,
541 dnsHistory: snapshot.dnsHistory,
542 portScanResults: snapshot.portScanResults,
543 hstsPreloaded: snapshot.hstsPreloaded,
544 availabilityResult: snapshot.availabilityResult,
545 suggestions: snapshot.suggestions
546 ),
547 registration: HistoryEntry.Registration(
548 ownership: snapshot.ownership,
549 ownershipHistory: snapshot.ownershipHistory,
550 domainPricing: snapshot.domainPricing
551 ),
552 intelligence: HistoryEntry.Intelligence(
553 reputation: snapshot.reputation
554 ),
555 provenance: HistoryEntry.Provenance(
556 appVersion: snapshot.appVersion,
557 resultSource: snapshot.resultSource,
558 dataSources: snapshot.dataSources,
559 provenanceBySection: snapshot.provenanceBySection,
560 availabilityConfidence: snapshot.availabilityConfidence,
561 ownershipConfidence: snapshot.ownershipConfidence,
562 subdomainConfidence: snapshot.subdomainConfidence,
563 emailSecurityConfidence: snapshot.emailSecurityConfidence,
564 geolocationConfidence: snapshot.geolocationConfidence,
565 isPartialSnapshot: snapshot.isPartialSnapshot,
566 validationIssues: snapshot.validationIssues,
567 resolverDisplayName: snapshot.resolverDisplayName,
568 resolverURLString: snapshot.resolverURLString,
569 totalLookupDurationMs: snapshot.totalLookupDurationMs
570 ),
571 summary: HistoryEntry.Summary(
572 primaryIP: Self.primaryIPAddress(from: snapshot),
573 finalRedirectURL: snapshot.redirectChain.last?.url,
574 tlsStatusSummary: Self.tlsSummary(from: snapshot),
575 emailSecuritySummary: Self.emailSummary(from: snapshot),
576 httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError,
577 changeSummary: changeSummary
578 ),
579 failures: HistoryEntry.Failures(
580 errorDetails: snapshot.errorDetails,
581 sslError: snapshot.sslError,
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 )
583597 )
584598
585599 history.insert(entry, at: 0)
DomainDig/DomainViewModel.swift +86 −72
@@ -1936,78 +1936,92 @@ final class DomainViewModel {
19361936 }
19371937
19381938 let entry = HistoryEntry(
1939 domain: snapshot.domain,
1940 timestamp: snapshot.timestamp,
1941 trackedDomainID: trackedDomainID,
1942 note: currentHistoryEntry?.note,
1943 dnsSections: snapshot.dnsSections,
1944 sslInfo: snapshot.sslInfo,
1945 httpHeaders: snapshot.httpHeaders,
1946 reachabilityResults: snapshot.reachabilityResults,
1947 ipGeolocation: snapshot.ipGeolocation,
1948 emailSecurity: snapshot.emailSecurity,
1949 mtaSts: snapshot.emailSecurity?.mtaSts,
1950 ownership: snapshot.ownership,
1951 ownershipHistory: snapshot.ownershipHistory,
1952 inferredProvider: intelligence.inferredProvider,
1953 priorProviders: intelligence.priorProviders,
1954 domainClassification: intelligence.domainClassification,
1955 ownershipTransitions: intelligence.ownershipTransitions,
1956 hostingTransitions: intelligence.hostingTransitions,
1957 subdomainHistory: intelligence.subdomainHistory,
1958 riskSignals: intelligence.riskSignals,
1959 intelligenceTimeline: intelligence.timelineEvents,
1960 ptrRecord: snapshot.ptrRecord,
1961 redirectChain: snapshot.redirectChain,
1962 subdomains: snapshot.subdomains,
1963 extendedSubdomains: snapshot.extendedSubdomains,
1964 dnsHistory: snapshot.dnsHistory,
1965 domainPricing: snapshot.domainPricing,
1966 reputation: snapshot.reputation,
1967 portScanResults: snapshot.portScanResults,
1968 hstsPreloaded: snapshot.hstsPreloaded,
1969 availabilityResult: snapshot.availabilityResult,
1970 suggestions: snapshot.suggestions,
1971 appVersion: snapshot.appVersion,
1972 resultSource: snapshot.resultSource,
1973 dataSources: snapshot.dataSources,
1974 provenanceBySection: snapshot.provenanceBySection,
1975 availabilityConfidence: snapshot.availabilityConfidence,
1976 ownershipConfidence: snapshot.ownershipConfidence,
1977 subdomainConfidence: snapshot.subdomainConfidence,
1978 emailSecurityConfidence: snapshot.emailSecurityConfidence,
1979 geolocationConfidence: snapshot.geolocationConfidence,
1980 errorDetails: snapshot.errorDetails,
1981 isPartialSnapshot: snapshot.isPartialSnapshot,
1982 validationIssues: snapshot.validationIssues,
1983 resolverDisplayName: snapshot.resolverDisplayName,
1984 resolverURLString: snapshot.resolverURLString,
1985 totalLookupDurationMs: snapshot.totalLookupDurationMs,
1986 primaryIP: Self.primaryIPAddress(from: snapshot),
1987 finalRedirectURL: Self.finalRedirectTarget(from: snapshot),
1988 tlsStatusSummary: Self.httpsSummary(from: snapshot),
1989 emailSecuritySummary: Self.emailSummary(from: snapshot),
1990 httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError,
1991 changeSummary: changeSummary,
1992 snapshotIndex: nextSnapshotIndex,
1993 previousSnapshotID: previousSnapshotID,
1994 changeCount: domainDiff?.changeCount ?? changeSummary?.changedSections.count ?? 0,
1995 severitySummary: changeSummary?.severity,
1996 sslError: snapshot.sslError,
1997 httpHeadersError: snapshot.httpHeadersError,
1998 reachabilityError: snapshot.reachabilityError,
1999 ipGeolocationError: snapshot.ipGeolocationError,
2000 emailSecurityError: snapshot.emailSecurityError,
2001 ownershipError: snapshot.ownershipError,
2002 ownershipHistoryError: snapshot.ownershipHistoryError,
2003 ptrError: snapshot.ptrError,
2004 redirectChainError: snapshot.redirectChainError,
2005 subdomainsError: snapshot.subdomainsError,
2006 extendedSubdomainsError: snapshot.extendedSubdomainsError,
2007 dnsHistoryError: snapshot.dnsHistoryError,
2008 domainPricingError: snapshot.domainPricingError,
2009 reputationError: snapshot.reputationError,
2010 portScanError: snapshot.portScanError
1939 identity: HistoryEntry.Identity(
1940 domain: snapshot.domain,
1941 timestamp: snapshot.timestamp,
1942 trackedDomainID: trackedDomainID,
1943 note: currentHistoryEntry?.note
1944 ),
1945 inspection: HistoryEntry.Inspection(
1946 dnsSections: snapshot.dnsSections,
1947 sslInfo: snapshot.sslInfo,
1948 httpHeaders: snapshot.httpHeaders,
1949 reachabilityResults: snapshot.reachabilityResults,
1950 ipGeolocation: snapshot.ipGeolocation,
1951 emailSecurity: snapshot.emailSecurity,
1952 mtaSts: snapshot.emailSecurity?.mtaSts,
1953 ptrRecord: snapshot.ptrRecord,
1954 redirectChain: snapshot.redirectChain,
1955 subdomains: snapshot.subdomains,
1956 extendedSubdomains: snapshot.extendedSubdomains,
1957 dnsHistory: snapshot.dnsHistory,
1958 portScanResults: snapshot.portScanResults,
1959 hstsPreloaded: snapshot.hstsPreloaded,
1960 availabilityResult: snapshot.availabilityResult,
1961 suggestions: snapshot.suggestions
1962 ),
1963 registration: HistoryEntry.Registration(
1964 ownership: snapshot.ownership,
1965 ownershipHistory: snapshot.ownershipHistory,
1966 inferredProvider: intelligence.inferredProvider,
1967 priorProviders: intelligence.priorProviders,
1968 domainClassification: intelligence.domainClassification,
1969 ownershipTransitions: intelligence.ownershipTransitions,
1970 hostingTransitions: intelligence.hostingTransitions,
1971 domainPricing: snapshot.domainPricing
1972 ),
1973 intelligence: HistoryEntry.Intelligence(
1974 subdomainHistory: intelligence.subdomainHistory,
1975 riskSignals: intelligence.riskSignals,
1976 intelligenceTimeline: intelligence.timelineEvents,
1977 reputation: snapshot.reputation
1978 ),
1979 provenance: HistoryEntry.Provenance(
1980 appVersion: snapshot.appVersion,
1981 resultSource: snapshot.resultSource,
1982 dataSources: snapshot.dataSources,
1983 provenanceBySection: snapshot.provenanceBySection,
1984 availabilityConfidence: snapshot.availabilityConfidence,
1985 ownershipConfidence: snapshot.ownershipConfidence,
1986 subdomainConfidence: snapshot.subdomainConfidence,
1987 emailSecurityConfidence: snapshot.emailSecurityConfidence,
1988 geolocationConfidence: snapshot.geolocationConfidence,
1989 isPartialSnapshot: snapshot.isPartialSnapshot,
1990 validationIssues: snapshot.validationIssues,
1991 resolverDisplayName: snapshot.resolverDisplayName,
1992 resolverURLString: snapshot.resolverURLString,
1993 totalLookupDurationMs: snapshot.totalLookupDurationMs
1994 ),
1995 summary: HistoryEntry.Summary(
1996 primaryIP: Self.primaryIPAddress(from: snapshot),
1997 finalRedirectURL: Self.finalRedirectTarget(from: snapshot),
1998 tlsStatusSummary: Self.httpsSummary(from: snapshot),
1999 emailSecuritySummary: Self.emailSummary(from: snapshot),
2000 httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError,
2001 changeSummary: changeSummary,
2002 snapshotIndex: nextSnapshotIndex,
2003 previousSnapshotID: previousSnapshotID,
2004 changeCount: domainDiff?.changeCount ?? changeSummary?.changedSections.count ?? 0,
2005 severitySummary: changeSummary?.severity
2006 ),
2007 failures: HistoryEntry.Failures(
2008 errorDetails: snapshot.errorDetails,
2009 sslError: snapshot.sslError,
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 )
20112025 )
20122026
20132027 if updateCurrentState {
DomainDig/Models.swift +190 −107
@@ -2399,113 +2399,196 @@ struct HistoryEntry: Identifiable, Codable {
23992399 var reputationError: String?
24002400 var portScanError: String?
24012401
2402 init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, note: String? = nil, dnsSections: [DNSSection],
2403 sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader],
2404 reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?,
2405 emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ownership: DomainOwnership? = nil,
2406 ownershipHistory: [DomainOwnershipHistoryEvent] = [],
2407 inferredProvider: InferredProviderFingerprint? = nil, priorProviders: [String] = [],
2408 domainClassification: DomainClassificationSummary? = nil,
2409 ownershipTransitions: [OwnershipTransitionEvent] = [],
2410 hostingTransitions: [HostingTransitionEvent] = [],
2411 subdomainHistory: [SubdomainHistoryEntry] = [],
2412 riskSignals: [IntelligenceRiskSignal] = [],
2413 intelligenceTimeline: [IntelligenceTimelineEvent] = [],
2414 ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [],
2415 extendedSubdomains: [DiscoveredSubdomain] = [], dnsHistory: [DNSHistoryEvent] = [],
2416 domainPricing: DomainPricingInsight? = nil,
2417 reputation: DomainReputationResult? = nil,
2418 portScanResults: [PortScanResult] = [],
2419 hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil,
2420 suggestions: [DomainSuggestionResult] = [], appVersion: String = "2.7.0",
2421 resultSource: LookupResultSource = .snapshot, dataSources: [String] = [],
2422 provenanceBySection: [LookupSectionKind: SectionProvenance] = [:],
2423 availabilityConfidence: ConfidenceLevel? = nil, ownershipConfidence: ConfidenceLevel? = nil,
2424 subdomainConfidence: ConfidenceLevel? = nil, emailSecurityConfidence: ConfidenceLevel? = nil,
2425 geolocationConfidence: ConfidenceLevel? = nil,
2426 errorDetails: [LookupSectionKind: InspectionFailure] = [:], isPartialSnapshot: Bool = false,
2427 validationIssues: [String] = [], resolverDisplayName: String, resolverURLString: String,
2428 totalLookupDurationMs: Int? = nil, primaryIP: String? = nil, finalRedirectURL: String? = nil,
2429 tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil,
2430 changeSummary: DomainChangeSummary? = nil, snapshotIndex: Int? = nil, previousSnapshotID: UUID? = nil,
2431 changeCount: Int = 0, severitySummary: ChangeSeverity? = nil, sslError: String? = nil, httpHeadersError: String? = nil,
2432 reachabilityError: String? = nil, ipGeolocationError: String? = nil,
2433 emailSecurityError: String? = nil, ownershipError: String? = nil, ownershipHistoryError: String? = nil,
2434 ptrError: String? = nil, redirectChainError: String? = nil, subdomainsError: String? = nil,
2435 extendedSubdomainsError: String? = nil, dnsHistoryError: String? = nil,
2436 domainPricingError: String? = nil, reputationError: String? = nil, portScanError: String? = nil) {
2437 self.domain = domain
2438 self.timestamp = timestamp
2439 self.trackedDomainID = trackedDomainID
2440 self.note = note
2441 self.dnsSections = dnsSections
2442 self.sslInfo = sslInfo
2443 self.httpHeaders = httpHeaders
2444 self.reachabilityResults = reachabilityResults
2445 self.ipGeolocation = ipGeolocation
2446 self.emailSecurity = emailSecurity
2447 self.mtaSts = mtaSts ?? emailSecurity?.mtaSts
2448 self.ownership = ownership
2449 self.ownershipHistory = ownershipHistory
2450 self.inferredProvider = inferredProvider
2451 self.priorProviders = priorProviders
2452 self.domainClassification = domainClassification
2453 self.ownershipTransitions = ownershipTransitions
2454 self.hostingTransitions = hostingTransitions
2455 self.subdomainHistory = subdomainHistory
2456 self.riskSignals = riskSignals
2457 self.intelligenceTimeline = intelligenceTimeline
2458 self.ptrRecord = ptrRecord
2459 self.redirectChain = redirectChain
2460 self.subdomains = subdomains
2461 self.extendedSubdomains = extendedSubdomains
2462 self.dnsHistory = dnsHistory
2463 self.domainPricing = domainPricing
2464 self.reputation = reputation
2465 self.portScanResults = portScanResults
2466 self.hstsPreloaded = hstsPreloaded
2467 self.availabilityResult = availabilityResult
2468 self.suggestions = suggestions
2469 self.appVersion = appVersion
2470 self.resultSource = resultSource
2471 self.dataSources = dataSources
2472 self.provenanceBySection = provenanceBySection
2473 self.availabilityConfidence = availabilityConfidence
2474 self.ownershipConfidence = ownershipConfidence
2475 self.subdomainConfidence = subdomainConfidence
2476 self.emailSecurityConfidence = emailSecurityConfidence
2477 self.geolocationConfidence = geolocationConfidence
2478 self.errorDetails = errorDetails
2479 self.isPartialSnapshot = isPartialSnapshot
2480 self.validationIssues = validationIssues
2481 self.resolverDisplayName = resolverDisplayName
2482 self.resolverURLString = resolverURLString
2483 self.totalLookupDurationMs = totalLookupDurationMs
2484 self.primaryIP = primaryIP
2485 self.finalRedirectURL = finalRedirectURL
2486 self.tlsStatusSummary = tlsStatusSummary
2487 self.emailSecuritySummary = emailSecuritySummary
2488 self.httpGradeSummary = httpGradeSummary
2489 self.changeSummary = changeSummary
2490 self.snapshotIndex = snapshotIndex
2491 self.previousSnapshotID = previousSnapshotID
2492 self.changeCount = changeCount
2493 self.severitySummary = severitySummary
2494 self.sslError = sslError
2495 self.httpHeadersError = httpHeadersError
2496 self.reachabilityError = reachabilityError
2497 self.ipGeolocationError = ipGeolocationError
2498 self.emailSecurityError = emailSecurityError
2499 self.ownershipError = ownershipError
2500 self.ownershipHistoryError = ownershipHistoryError
2501 self.ptrError = ptrError
2502 self.redirectChainError = redirectChainError
2503 self.subdomainsError = subdomainsError
2504 self.extendedSubdomainsError = extendedSubdomainsError
2505 self.dnsHistoryError = dnsHistoryError
2506 self.domainPricingError = domainPricingError
2507 self.reputationError = reputationError
2508 self.portScanError = portScanError
2402 // A full inspection produces 73 stored properties, and passing them as one
2403 // flat argument list made a 72-parameter initializer no call site could read
2404 // (SonarCloud swift:S107). They are grouped below by what they describe.
2405 //
2406 // The stored properties stay flat, deliberately. This type is persisted to
2407 // UserDefaults and read back out of backup files, and both decode paths drop
2408 // entries that will not parse rather than raising — so nesting a value would
2409 // change the encoded shape and silently discard history written by an older
2410 // build. These groups exist at the call boundary only; the JSON is unchanged,
2411 // which HistoryEntryCodableTests pins.
2412
2413 /// What was inspected, and when.
2414 struct Identity {
2415 var domain: String
2416 var timestamp: Date
2417 var trackedDomainID: UUID? = nil
2418 var note: String? = nil
2419 }
2420
2421 /// The live probe results for this snapshot.
2422 struct Inspection {
2423 var dnsSections: [DNSSection]
2424 var sslInfo: SSLCertificateInfo?
2425 var httpHeaders: [HTTPHeader]
2426 var reachabilityResults: [PortReachability]
2427 var ipGeolocation: IPGeolocation?
2428 var emailSecurity: EmailSecurityResult? = nil
2429 var mtaSts: MTASTSResult? = nil
2430 var ptrRecord: String? = nil
2431 var redirectChain: [RedirectHop] = []
2432 var subdomains: [DiscoveredSubdomain] = []
2433 var extendedSubdomains: [DiscoveredSubdomain] = []
2434 var dnsHistory: [DNSHistoryEvent] = []
2435 var portScanResults: [PortScanResult] = []
2436 var hstsPreloaded: Bool? = nil
2437 var availabilityResult: DomainAvailabilityResult? = nil
2438 var suggestions: [DomainSuggestionResult] = []
2439 }
2440
2441 /// Who owns the domain and where it is hosted.
2442 struct Registration {
2443 var ownership: DomainOwnership? = nil
2444 var ownershipHistory: [DomainOwnershipHistoryEvent] = []
2445 var inferredProvider: InferredProviderFingerprint? = nil
2446 var priorProviders: [String] = []
2447 var domainClassification: DomainClassificationSummary? = nil
2448 var ownershipTransitions: [OwnershipTransitionEvent] = []
2449 var hostingTransitions: [HostingTransitionEvent] = []
2450 var domainPricing: DomainPricingInsight? = nil
2451 }
2452
2453 /// Derived signals rather than direct observations.
2454 struct Intelligence {
2455 var subdomainHistory: [SubdomainHistoryEntry] = []
2456 var riskSignals: [IntelligenceRiskSignal] = []
2457 var intelligenceTimeline: [IntelligenceTimelineEvent] = []
2458 var reputation: DomainReputationResult? = nil
2459 }
2460
2461 /// Where the data came from, and how much to trust it.
2462 struct Provenance {
2463 var appVersion: String = "2.7.0"
2464 var resultSource: LookupResultSource = .snapshot
2465 var dataSources: [String] = []
2466 var provenanceBySection: [LookupSectionKind: SectionProvenance] = [:]
2467 var availabilityConfidence: ConfidenceLevel? = nil
2468 var ownershipConfidence: ConfidenceLevel? = nil
2469 var subdomainConfidence: ConfidenceLevel? = nil
2470 var emailSecurityConfidence: ConfidenceLevel? = nil
2471 var geolocationConfidence: ConfidenceLevel? = nil
2472 var isPartialSnapshot: Bool = false
2473 var validationIssues: [String] = []
2474 var resolverDisplayName: String
2475 var resolverURLString: String
2476 var totalLookupDurationMs: Int? = nil
2477 }
2478
2479 /// Precomputed display values and change tracking.
2480 struct Summary {
2481 var primaryIP: String? = nil
2482 var finalRedirectURL: String? = nil
2483 var tlsStatusSummary: String? = nil
2484 var emailSecuritySummary: String? = nil
2485 var httpGradeSummary: String? = nil
2486 var changeSummary: DomainChangeSummary? = nil
2487 var snapshotIndex: Int? = nil
2488 var previousSnapshotID: UUID? = nil
2489 var changeCount: Int = 0
2490 var severitySummary: ChangeSeverity? = nil
2491 }
2492
2493 /// Per-section failures: a snapshot records what it could not collect.
2494 struct Failures {
2495 var errorDetails: [LookupSectionKind: InspectionFailure] = [:]
2496 var sslError: String? = nil
2497 var httpHeadersError: String? = nil
2498 var reachabilityError: String? = nil
2499 var ipGeolocationError: String? = nil
2500 var emailSecurityError: String? = nil
2501 var ownershipError: String? = nil
2502 var ownershipHistoryError: String? = nil
2503 var ptrError: String? = nil
2504 var redirectChainError: String? = nil
2505 var subdomainsError: String? = nil
2506 var extendedSubdomainsError: String? = nil
2507 var dnsHistoryError: String? = nil
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
25092592 }
25102593
25112594 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}