1 contributor
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()
}
}