966 lines · 37.6 KB · Swift
RawDownload
import Foundation#if canImport(CryptoKit)import CryptoKit#endif#if canImport(Darwin)import Darwin#elseif canImport(Glibc)import Glibc#elseif canImport(Musl)import Musl#endif public enum CookieHeaderCache {    public enum Scope: Sendable, Equatable {        case managedAccount(UUID)        case managedStoreUnreadable        case profileHome(String)         public var isolationIdentifier: String {            switch self {            case let .managedAccount(accountID):                "managed.\(accountID.uuidString.lowercased())"            case .managedStoreUnreadable:                "managed-store-unreadable"            case let .profileHome(path):                "profile-home.\(Self.profileHomeDigest(path))"            }        }         fileprivate var keychainIdentifier: String {            self.isolationIdentifier        }         private static func profileHomeDigest(_ path: String) -> String {            let standardized = URL(fileURLWithPath: path, isDirectory: true).standardizedFileURL.path            #if canImport(CryptoKit)            return SHA256.hash(data: Data(standardized.utf8))                .map { String(format: "%02x", $0) }                .joined()            #else            let digest = standardized.utf8.reduce(UInt64(14_695_981_039_346_656_037)) { partial, byte in                (partial ^ UInt64(byte)) &* 1_099_511_628_211            }            return String(digest, radix: 16)            #endif        }    }     public struct Entry: Codable, Sendable {        public let cookieHeader: String        public let storedAt: Date        public let sourceLabel: String         public init(cookieHeader: String, storedAt: Date, sourceLabel: String) {            self.cookieHeader = cookieHeader            self.storedAt = storedAt            self.sourceLabel = sourceLabel        }    }     public struct ClearSummary: Equatable, Sendable {        public let clearedCount: Int        public let failedCount: Int         public init(clearedCount: Int, failedCount: Int) {            self.clearedCount = clearedCount            self.failedCount = failedCount        }    }     private static let log = CodexBarLog.logger(LogCategories.cookieCache)    private static let legacyBaseURLOverrideLock = NSLock()    private nonisolated(unsafe) static var legacyBaseURLOverride: URL?     private struct DisplaySnapshot {        let entry: Entry?        let refreshAfter: Date    }     private enum LoadOutcome {        case authoritative(Entry?, loadedFromLegacy: Bool)        case temporarilyUnavailable    }     private static let legacyMutationLock = NSLock()    private static let displayCacheLock = NSLock()    private nonisolated(unsafe) static var displayCache: [KeychainCacheStore.Key: DisplaySnapshot] = [:]    private nonisolated(unsafe) static var displayGenerations: [KeychainCacheStore.Key: UInt64] = [:]    private nonisolated(unsafe) static var displayRevalidationsInFlight: Set<KeychainCacheStore.Key> = []    private nonisolated(unsafe) static var legacyMigrationsInFlight: Set<UsageProvider> = []    private nonisolated(unsafe) static var displayStalenessIntervalOverride: TimeInterval?    private nonisolated(unsafe) static var displayUnavailableRetryIntervalOverride: TimeInterval?    private static let displayStalenessInterval: TimeInterval = 30    private static let displayUnavailableRetryInterval: TimeInterval = 1    #if DEBUG    @TaskLocal private static var taskLegacyBaseURLOverride: URL?    @TaskLocal private static var legacyRemovalFailureOverride = false    #endif     private enum LegacyRemovalResult: Equatable {        case removed        case missing        case failed    }     /// Settings rows render the "Cached: …" cookie label inside SwiftUI body evaluations, which    /// run repeatedly within a single AppKit layout pass. Each `load` pays a synchronous    /// securityd round-trip and decrypt, so display paths use this memoized variant instead: it    /// returns the last known entry immediately and revalidates a stale snapshot off the calling    /// path. In-process `store` and `clear` calls update the snapshot synchronously; only the    /// first lookup per key pays the keychain read.    public static func loadForDisplay(provider: UsageProvider, scope: Scope? = nil) -> Entry? {        let key = self.key(for: provider, scope: scope)        let (cached, generation) = self.beginDisplayRead(key: key)        guard let cached else {            switch self.loadOutcome(provider: provider, scope: scope, migrateLegacy: false) {            case let .authoritative(entry, loadedFromLegacy):                let committed = self.commitDisplaySnapshotIfCurrent(key: key, entry: entry, generation: generation)                if loadedFromLegacy {                    self.scheduleLegacyMigration(provider: provider)                }                return committed            case .temporarilyUnavailable:                return self.commitTemporaryDisplaySnapshotIfCurrent(key: key, generation: generation)            }        }        if Date() >= cached.refreshAfter {            self.scheduleDisplayRevalidation(provider: provider, scope: scope, key: key, generation: generation)        }        return cached.entry    }     /// Registers the key before the Keychain read starts so `clearAll` can invalidate an    /// in-flight first population even when no display snapshot exists yet.    private static func beginDisplayRead(key: KeychainCacheStore.Key) -> (DisplaySnapshot?, UInt64) {        self.displayCacheLock.lock()        defer { self.displayCacheLock.unlock() }        let generation = self.displayGenerations[key] ?? 0        self.displayGenerations[key] = generation        return (self.displayCache[key], generation)    }     private static func scheduleDisplayRevalidation(        provider: UsageProvider,        scope: Scope?,        key: KeychainCacheStore.Key,        generation: UInt64)    {        self.displayCacheLock.lock()        let inserted = self.displayRevalidationsInFlight.insert(key).inserted        self.displayCacheLock.unlock()        guard inserted else { return }        Task(priority: .utility) {            self.revalidateDisplaySnapshot(provider: provider, scope: scope, key: key, generation: generation)        }    }     private static func revalidateDisplaySnapshot(        provider: UsageProvider,        scope: Scope?,        key: KeychainCacheStore.Key,        generation: UInt64)    {        switch self.loadOutcome(provider: provider, scope: scope, migrateLegacy: false) {        case let .authoritative(entry, loadedFromLegacy):            _ = self.commitDisplaySnapshotIfCurrent(key: key, entry: entry, generation: generation)            if loadedFromLegacy {                self.scheduleLegacyMigration(provider: provider)            }        case .temporarilyUnavailable:            self.deferDisplayRetryIfCurrent(key: key, generation: generation)        }        self.displayCacheLock.lock()        self.displayRevalidationsInFlight.remove(key)        self.displayCacheLock.unlock()    }     private static func scheduleLegacyMigration(provider: UsageProvider) {        self.displayCacheLock.lock()        let inserted = self.legacyMigrationsInFlight.insert(provider).inserted        self.displayCacheLock.unlock()        guard inserted else { return }        Task(priority: .utility) {            _ = self.migrateLegacyEntryIfNeeded(provider: provider)            _ = self.displayCacheLock.withLock {                self.legacyMigrationsInFlight.remove(provider)            }        }    }     /// Keychain reads for the display cache happen outside the lock, so a concurrent `store` or    /// `clear` can publish newer state before the read commits. Each mutation bumps the per-key    /// generation; a read only commits if the generation it started from is still current, and    /// otherwise returns whatever newer snapshot won the race.    private static func commitDisplaySnapshotIfCurrent(        key: KeychainCacheStore.Key,        entry: Entry?,        generation: UInt64) -> Entry?    {        self.displayCacheLock.lock()        defer { self.displayCacheLock.unlock() }        guard self.displayGenerations[key, default: 0] == generation else {            return self.displayCache[key]?.entry        }        self.displayCache[key] = self.authoritativeDisplaySnapshot(entry: entry)        return entry    }     private static func commitTemporaryDisplaySnapshotIfCurrent(        key: KeychainCacheStore.Key,        generation: UInt64) -> Entry?    {        self.displayCacheLock.lock()        defer { self.displayCacheLock.unlock() }        guard self.displayGenerations[key, default: 0] == generation else {            return self.displayCache[key]?.entry        }        if let current = self.displayCache[key] {            return current.entry        }        self.displayCache[key] = self.temporaryDisplaySnapshot(entry: nil)        return nil    }     private static func deferDisplayRetryIfCurrent(key: KeychainCacheStore.Key, generation: UInt64) {        self.displayCacheLock.lock()        defer { self.displayCacheLock.unlock() }        guard self.displayGenerations[key, default: 0] == generation,              let current = self.displayCache[key]        else { return }        self.displayCache[key] = self.temporaryDisplaySnapshot(entry: current.entry)    }     private static func updateDisplaySnapshot(key: KeychainCacheStore.Key, entry: Entry?) {        self.displayCacheLock.lock()        self.displayCache[key] = self.authoritativeDisplaySnapshot(entry: entry)        self.displayGenerations[key, default: 0] += 1        self.displayCacheLock.unlock()    }     private static func invalidateDisplaySnapshot(key: KeychainCacheStore.Key) {        self.displayCacheLock.lock()        self.displayCache.removeValue(forKey: key)        self.displayGenerations[key, default: 0] += 1        self.displayCacheLock.unlock()    }     private static func currentDisplayEntry(key: KeychainCacheStore.Key) -> Entry? {        self.displayCacheLock.lock()        defer { self.displayCacheLock.unlock() }        return self.displayCache[key]?.entry    }     private static func authoritativeDisplaySnapshot(entry: Entry?) -> DisplaySnapshot {        DisplaySnapshot(entry: entry, refreshAfter: Date().addingTimeInterval(self.currentDisplayStalenessInterval))    }     private static func temporaryDisplaySnapshot(entry: Entry?) -> DisplaySnapshot {        DisplaySnapshot(            entry: entry,            refreshAfter: Date().addingTimeInterval(self.currentDisplayUnavailableRetryInterval))    }     private static var currentDisplayStalenessInterval: TimeInterval {        self.displayStalenessIntervalOverride ?? self.displayStalenessInterval    }     private static var currentDisplayUnavailableRetryInterval: TimeInterval {        self.displayUnavailableRetryIntervalOverride ?? self.displayUnavailableRetryInterval    }     static func setDisplayStalenessIntervalOverrideForTesting(_ interval: TimeInterval?) {        self.displayStalenessIntervalOverride = interval    }     static func setDisplayUnavailableRetryIntervalOverrideForTesting(_ interval: TimeInterval?) {        self.displayUnavailableRetryIntervalOverride = interval    }     static func resetDisplayCacheForTesting() {        self.displayCacheLock.lock()        self.displayCache.removeAll()        self.displayGenerations.removeAll()        self.displayRevalidationsInFlight.removeAll()        self.legacyMigrationsInFlight.removeAll()        self.displayCacheLock.unlock()    }     static func beginDisplayReadGenerationForTesting(provider: UsageProvider, scope: Scope? = nil) -> UInt64 {        self.beginDisplayRead(key: self.key(for: provider, scope: scope)).1    }     static func currentDisplayEntryForTesting(provider: UsageProvider, scope: Scope? = nil) -> Entry? {        self.currentDisplayEntry(key: self.key(for: provider, scope: scope))    }     #if DEBUG    static func withLegacyRemovalFailureForTesting<T>(_ operation: () throws -> T) rethrows -> T {        try self.$legacyRemovalFailureOverride.withValue(true) {            try operation()        }    }    #endif     @discardableResult    static func commitDisplaySnapshotIfCurrentForTesting(        provider: UsageProvider,        scope: Scope? = nil,        entry: Entry?,        generation: UInt64) -> Entry?    {        self.commitDisplaySnapshotIfCurrent(            key: self.key(for: provider, scope: scope),            entry: entry,            generation: generation)    }     public static func load(provider: UsageProvider, scope: Scope? = nil) -> Entry? {        switch self.loadOutcome(provider: provider, scope: scope, migrateLegacy: true) {        case let .authoritative(entry, _):            entry        case .temporarilyUnavailable:            nil        }    }     static func loadSerialized(provider: UsageProvider, scope: Scope? = nil) -> Entry? {        do {            return try self.withLegacyMutationLock {                let key = self.key(for: provider, scope: scope)                switch KeychainCacheStore.load(key: key, as: Entry.self) {                case let .found(entry):                    return entry                case .temporarilyUnavailable:                    return nil                case .invalid:                    KeychainCacheStore.clear(key: key)                case .missing:                    break                }                guard scope == nil else { return nil }                return self.migrateLegacyEntryIfNeededLocked(provider: provider)            }        } catch {            self.log.error("Cookie cache serialized load failed: \(error)")            return nil        }    }     private static func loadOutcome(        provider: UsageProvider,        scope: Scope?,        migrateLegacy: Bool) -> LoadOutcome    {        let key = self.key(for: provider, scope: scope)        switch KeychainCacheStore.load(key: key, as: Entry.self) {        case let .found(entry):            self.log.debug("Cookie cache hit", metadata: ["provider": provider.rawValue])            return .authoritative(entry, loadedFromLegacy: false)        case .temporarilyUnavailable:            self.log.debug("Cookie cache temporarily unavailable", metadata: ["provider": provider.rawValue])            return .temporarilyUnavailable        case .invalid:            self.log.warning("Cookie cache invalid; clearing", metadata: ["provider": provider.rawValue])            KeychainCacheStore.clear(key: key)        case .missing:            self.log.debug("Cookie cache miss", metadata: ["provider": provider.rawValue])        }         guard scope == nil else { return .authoritative(nil, loadedFromLegacy: false) }        if migrateLegacy {            return .authoritative(                self.migrateLegacyEntryIfNeeded(provider: provider),                loadedFromLegacy: false)        }        guard let legacy = self.loadLegacyEntry(for: provider) else {            return .authoritative(nil, loadedFromLegacy: false)        }        return .authoritative(legacy, loadedFromLegacy: true)    }     /// Re-reads both stores while serialized with global cookie mutations. A display-triggered    /// migration may be queued before a clear, so using the captured legacy entry here would    /// otherwise allow the delayed task to restore credentials the user just removed.    private static func migrateLegacyEntryIfNeeded(provider: UsageProvider) -> Entry? {        do {            return try self.withLegacyMutationLock {                self.migrateLegacyEntryIfNeededLocked(provider: provider)            }        } catch {            self.log.error("Cookie cache migration lock failed: \(error)")            return nil        }    }     private static func migrateLegacyEntryIfNeededLocked(provider: UsageProvider) -> Entry? {        let key = self.key(for: provider, scope: nil)        switch KeychainCacheStore.load(key: key, as: Entry.self) {        case let .found(entry):            _ = self.removeLegacyEntry(for: provider)            return entry        case .temporarilyUnavailable:            return nil        case .invalid:            KeychainCacheStore.clear(key: key)        case .missing:            break        }         guard let legacy = self.loadLegacyEntry(for: provider) else { return nil }        if KeychainCacheStore.storeResult(key: key, entry: legacy),           self.removeLegacyEntry(for: provider) == .removed        {            self.log.debug("Cookie cache migrated from legacy store", metadata: ["provider": provider.rawValue])        }        return legacy    }     public static func store(        provider: UsageProvider,        scope: Scope? = nil,        cookieHeader: String,        sourceLabel: String,        now: Date = Date())    {        let trimmed = cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines)        guard let normalized = CookieHeaderNormalizer.normalize(trimmed), !normalized.isEmpty else {            self.clear(provider: provider, scope: scope)            return        }        let entry = Entry(cookieHeader: normalized, storedAt: now, sourceLabel: sourceLabel)        do {            try self.withLegacyMutationLock {                _ = self.store(entry: entry, provider: provider, scope: scope, sourceLabel: sourceLabel)            }        } catch {            self.log.error("Cookie cache store lock failed: \(error)")        }    }     /// Stores only while the cache still matches the entry observed before an asynchronous refresh.    /// A nil expected entry means the cache must still be empty.    @discardableResult    static func storeIfCurrent(        provider: UsageProvider,        scope: Scope? = nil,        expected: Entry?,        cookieHeader: String,        sourceLabel: String,        now: Date = Date()) -> Bool    {        let trimmed = cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines)        guard let normalized = CookieHeaderNormalizer.normalize(trimmed), !normalized.isEmpty else { return false }        let entry = Entry(cookieHeader: normalized, storedAt: now, sourceLabel: sourceLabel)        do {            return try self.withLegacyMutationLock {                guard self.currentEntryMatches(expected, provider: provider, scope: scope) else { return false }                return self.store(entry: entry, provider: provider, scope: scope, sourceLabel: sourceLabel)            }        } catch {            self.log.error("Cookie cache conditional store lock failed: \(error)")            return false        }    }     /// Clears only while the cache still matches the entry that produced a failed asynchronous refresh.    @discardableResult    static func clearIfCurrent(        provider: UsageProvider,        scope: Scope? = nil,        expected: Entry?) -> Bool    {        do {            return try self.withLegacyMutationLock {                guard self.currentEntryMatches(expected, provider: provider, scope: scope) else { return false }                // Keep the expected Keychain row intact when legacy cleanup fails so fallback can replace it.                if scope == nil, self.removeLegacyEntry(for: provider) == .failed {                    return false                }                let key = self.key(for: provider, scope: scope)                let result = KeychainCacheStore.clearResult(key: key)                guard result != .failed else { return false }                self.updateDisplaySnapshot(key: key, entry: nil)                return true            }        } catch {            self.log.error("Cookie cache conditional clear lock failed: \(error)")            return false        }    }     private static func currentEntryMatches(        _ expected: Entry?,        provider: UsageProvider,        scope: Scope?) -> Bool    {        let key = self.key(for: provider, scope: scope)        switch KeychainCacheStore.load(key: key, as: Entry.self) {        case let .found(current):            return self.entriesMatch(current, expected)

Lines 1–500 of 966.