1 contributor
82 lines | 3.543kb
import CryptoKit
import Foundation

enum HashService {
    private static let iso8601Formatter: ISO8601DateFormatter = {
        let f = ISO8601DateFormatter()
        f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
        return f
    }()

    // SHA256 of "typeIdentifier|totalCount|earliestDateISO|latestDateISO"
    // ⚠️ MVP limitation: hash covers only count + date range, not value distribution.
    // silentReplacement detection based on this hash is best-effort only —
    // it will miss replacements that preserve total count and date boundaries.
    static func typeHash(
        typeIdentifier: String,
        totalCount: Int,
        earliestDate: Date?,
        latestDate: Date?
    ) -> String {
        let earliest = earliestDate.map { iso8601Formatter.string(from: $0) } ?? ""
        let latest   = latestDate.map   { iso8601Formatter.string(from: $0) } ?? ""
        let input = "\(typeIdentifier)|\(totalCount)|\(earliest)|\(latest)"
        let digest = SHA256.hash(data: Data(input.utf8))
        return digest.map { String(format: "%02x", $0) }.joined()
    }

    static func typeHash(typeIdentifier: String, recordFingerprints: [String]) -> String {
        var hasher = SHA256()
        hasher.update(data: Data(typeIdentifier.utf8))
        for fingerprint in recordFingerprints.sorted() {
            hasher.update(data: Data("|".utf8))
            hasher.update(data: Data(fingerprint.utf8))
        }
        let digest = hasher.finalize()
        return digest.map { String(format: "%02x", $0) }.joined()
    }

    static func sampleFingerprint(
        typeIdentifier: String,
        sampleUUID: String,
        startDate: Date,
        endDate: Date
    ) -> String {
        let input = [
            typeIdentifier,
            sampleUUID,
            iso8601Formatter.string(from: startDate),
            iso8601Formatter.string(from: endDate)
        ].joined(separator: "|")
        let digest = SHA256.hash(data: Data(input.utf8))
        return digest.map { String(format: "%02x", $0) }.joined()
    }

    static func sampleUUIDHash(_ sampleUUID: String) -> String {
        let digest = SHA256.hash(data: Data(sampleUUID.utf8))
        return digest.map { String(format: "%02x", $0) }.joined()
    }

    // Per-snapshot: sort TypeCounts by typeIdentifier, SHA256 of concatenated type hashes.
    // Filter criterion: quality == .complete; do not use contentHash != "" as a proxy.
    // A TypeCount with quality = .failed but contentHash = "nonEmpty" must be excluded.
    static func snapshotChecksum(typeCounts: [TypeCount]) -> String {
        let completeHashes = typeCounts
            .filter { $0.quality == .complete }
            .sorted { $0.typeIdentifier < $1.typeIdentifier }
            .map { $0.contentHash }
            .joined()
        let digest = SHA256.hash(data: Data(completeHashes.utf8))
        return digest.map { String(format: "%02x", $0) }.joined()
    }

    // SHA256 of sorted active typeIdentifier strings.
    // ⚠️ Covers the FULL intended registry (selectedTypeIDs), including types that may have
    // failed, timed out, or been unauthorized — never filter down to only the successfully-
    // fetched subset. A query failure must not silently change the registry hash.
    static func typeSetHash(typeIDs: [String]) -> String {
        let sorted = typeIDs.sorted().joined(separator: "|")
        let digest = SHA256.hash(data: Data(sorted.utf8))
        return digest.map { String(format: "%02x", $0) }.joined()
    }
}