LocalData/Sources/LocalData/Services/StorageRouter.swift

489 lines
20 KiB
Swift

import Foundation
import WatchConnectivity
/// The main storage router that coordinates all storage operations.
/// Uses specialized helper actors for each storage domain.
public actor StorageRouter: StorageProviding {
public static let shared = StorageRouter()
private var registeredKeyNames: Set<String> = []
private var registeredEntries: [AnyStorageKey] = []
private var storageConfiguration: StorageConfiguration = .default
private let keychain: KeychainStoring
/// Initialize a new StorageRouter.
/// Internal for testing isolation via @testable import.
/// Consumers should use the `shared` singleton.
internal init(keychain: KeychainStoring = KeychainHelper.shared) {
self.keychain = keychain
}
// MARK: - Configuration
/// Updates the encryption configuration.
/// > [!WARNING]
/// > Changing these constants in an existing app will cause the app to look for the master key
/// > under a new name. Previously encrypted data will be lost.
public func updateEncryptionConfiguration(_ configuration: EncryptionConfiguration) async {
await EncryptionHelper.shared.updateConfiguration(configuration)
await EncryptionHelper.shared.updateKeychainHelper(keychain)
}
/// Updates the sync configuration.
public func updateSyncConfiguration(_ configuration: SyncConfiguration) async {
await SyncHelper.shared.updateConfiguration(configuration)
}
/// Updates the file storage configuration.
public func updateFileStorageConfiguration(_ configuration: FileStorageConfiguration) async {
await FileStorageHelper.shared.updateConfiguration(configuration)
}
/// Updates the global storage configuration (defaults).
public func updateStorageConfiguration(_ configuration: StorageConfiguration) {
self.storageConfiguration = configuration
}
// MARK: - Key Material Providers
/// Registers a key material provider for external encryption policies.
public func registerKeyMaterialProvider(
_ provider: any KeyMaterialProviding,
for source: KeyMaterialSource
) async {
await EncryptionHelper.shared.updateKeychainHelper(keychain)
await EncryptionHelper.shared.registerKeyMaterialProvider(provider, for: source)
}
/// Registers a catalog of known storage keys for audit and validation.
/// When registered, all storage operations will verify keys are listed.
/// - Parameters:
/// - catalog: The catalog type to register.
/// - migrateImmediately: If true, triggers a proactive migration (sweep) for all keys in the catalog.
public func registerCatalog<C: StorageKeyCatalog>(_ catalog: C.Type, migrateImmediately: Bool = false) async throws {
let entries = catalog.allKeys
try validateDescription(entries)
try validateUniqueKeys(entries)
registeredKeyNames = Set(entries.map { $0.descriptor.name })
registeredEntries = entries
if migrateImmediately {
try await migrateAllRegisteredKeys()
}
}
/// Triggers a proactive migration (sweep) for all registered storage keys.
/// This "drains" any legacy data into the modern storage locations.
public func migrateAllRegisteredKeys() async throws {
Logger.debug(">>> [STORAGE] STARTING GLOBAL MIGRATION SWEEP")
for entry in registeredEntries {
try await entry.migrate(on: self)
}
Logger.debug("<<< [STORAGE] GLOBAL MIGRATION SWEEP COMPLETE")
}
// MARK: - StorageProviding Implementation
/// Stores a value for the given key.
/// - Parameters:
/// - value: The value to store.
/// - key: The storage key defining where and how to store.
/// - Throws: Various errors depending on the storage domain and security policy.
public func set<Key: StorageKey>(_ value: Key.Value, for key: Key) async throws {
Logger.debug(">>> [STORAGE] SET: \(key.name) [Domain: \(key.domain)]")
try validateCatalogRegistration(for: key)
try validatePlatformAvailability(for: key)
let data = try serialize(value, with: key.serializer)
let securedData = try await applySecurity(data, for: .from(key), isEncrypt: true)
try await store(securedData, for: key)
try await handleSync(key, data: securedData)
Logger.debug("<<< [STORAGE] SET SUCCESS: \(key.name)")
}
/// Retrieves a value for the given key.
/// - Parameter key: The storage key to retrieve.
/// - Returns: The stored value.
/// - Throws: `StorageError.notFound` if no value exists, plus domain-specific errors.
public func get<Key: StorageKey>(_ key: Key) async throws -> Key.Value {
Logger.debug(">>> [STORAGE] GET: \(key.name)")
try validateCatalogRegistration(for: key)
try validatePlatformAvailability(for: key)
// 1. Try primary location
if let securedData = try await retrieve(for: .from(key)) {
let data = try await applySecurity(securedData, for: .from(key), isEncrypt: false)
let result = try deserialize(data, with: key.serializer)
Logger.debug("<<< [STORAGE] GET SUCCESS: \(key.name)")
return result
}
// 2. Try migration sources
for source in key.migrationSources {
let descriptor = source.descriptor
Logger.debug("!!! [STORAGE] MIGRATION: Checking source '\(descriptor.name)' for key '\(key.name)'")
if let securedOldData = try await retrieve(for: descriptor) {
Logger.info("!!! [STORAGE] MIGRATION: Found data for '\(key.name)' at legacy source '\(descriptor.name)'. Migrating...")
// Unsecure using OLD key's policy
let oldData = try await applySecurity(securedOldData, for: descriptor, isEncrypt: false)
// Decode using NEW key's serializer (assuming types are compatible)
let value = try deserialize(oldData, with: key.serializer)
// Store in NEW location with NEW security
// This will also handle sync and catalog validation via recursive call to public set
try await self.set(value, for: key)
// Delete OLD data
try await delete(for: descriptor)
Logger.info("!!! [STORAGE] MIGRATION: Successfully migrated '\(key.name)' from '\(descriptor.name)'")
return value
}
}
Logger.debug("<<< [STORAGE] GET NOT FOUND: \(key.name)")
throw StorageError.notFound
}
/// Manually triggers migration from legacy sources for a specific key.
/// This is useful for "draining" old keys at app startup even if the destination already has data.
/// - Parameter key: The storage key to migrate.
/// - Throws: Various storage and serialization errors.
public func migrate<Key: StorageKey>(for key: Key) async throws {
Logger.debug(">>> [STORAGE] MANUAL MIGRATION: \(key.name)")
for source in key.migrationSources {
let descriptor = source.descriptor
if let securedOldData = try await retrieve(for: descriptor) {
Logger.info("!!! [STORAGE] MANUAL MIGRATION: Migrating found data from '\(descriptor.name)' to '\(key.name)'")
let oldData = try await applySecurity(securedOldData, for: descriptor, isEncrypt: false)
let value = try deserialize(oldData, with: key.serializer)
// Store in NEW location
try await self.set(value, for: key)
// Delete OLD data
try await delete(for: descriptor)
}
}
Logger.debug("<<< [STORAGE] MANUAL MIGRATION COMPLETE: \(key.name)")
}
/// Removes the value for the given key.
/// - Parameter key: The storage key to remove.
/// - Throws: Domain-specific errors if removal fails.
public func remove<Key: StorageKey>(_ key: Key) async throws {
try validateCatalogRegistration(for: key)
try validatePlatformAvailability(for: key)
try await delete(for: .from(key))
}
/// Checks if a value exists for the given key.
/// - Parameter key: The storage key to check.
/// - Returns: True if a value exists.
public func exists<Key: StorageKey>(_ key: Key) async throws -> Bool {
try validateCatalogRegistration(for: key)
try validatePlatformAvailability(for: key)
switch key.domain {
case .userDefaults(let suite):
return try await UserDefaultsHelper.shared.exists(forKey: key.name, suite: suite)
case .appGroupUserDefaults(let identifier):
let resolvedId = try resolveIdentifier(identifier)
return try await UserDefaultsHelper.shared.exists(forKey: key.name, appGroupIdentifier: resolvedId)
case .keychain(let service):
let resolvedService = try resolveService(service)
return try await keychain.exists(service: resolvedService, key: key.name)
case .fileSystem(let directory), .encryptedFileSystem(let directory):
return await FileStorageHelper.shared.exists(in: directory, fileName: key.name)
case .appGroupFileSystem(let identifier, let directory):
let resolvedId = try resolveIdentifier(identifier)
return await FileStorageHelper.shared.exists(
in: directory,
fileName: key.name,
appGroupIdentifier: resolvedId
)
}
}
// MARK: - Platform Validation
private func validatePlatformAvailability<Key: StorageKey>(for key: Key) throws {
#if os(watchOS)
if key.availability == .phoneOnly {
throw StorageError.phoneOnlyKeyAccessedOnWatch(key.name)
}
#elseif os(iOS)
if key.availability == .watchOnly {
throw StorageError.watchOnlyKeyAccessedOnPhone(key.name)
}
#endif
}
private func validateCatalogRegistration<Key: StorageKey>(for key: Key) throws {
guard !registeredKeyNames.isEmpty else { return }
guard registeredKeyNames.contains(key.name) else {
#if DEBUG
if !isRunningTests {
assertionFailure("StorageKey not registered in catalog: \(key.name)")
}
#endif
throw StorageError.unregisteredKey(key.name)
}
}
private var isRunningTests: Bool {
// Broad check for any test-related environment variables or classes
if ProcessInfo.processInfo.environment.keys.contains(where: {
$0.hasPrefix("XCTest") || $0.hasPrefix("SWIFT_TESTING") || $0.hasPrefix("SWIFT_DETERMINISTIC")
}) {
return true
}
return NSClassFromString("XCTestCase") != nil || NSClassFromString("Testing.Test") != nil
}
private func validateUniqueKeys(_ entries: [AnyStorageKey]) throws {
var exactNames: [String: Int] = [:]
var duplicates: [String] = []
for entry in entries {
exactNames[entry.descriptor.name, default: 0] += 1
}
for (name, count) in exactNames where count > 1 {
duplicates.append(name)
}
guard duplicates.isEmpty else {
throw StorageError.duplicateRegisteredKeys(duplicates.sorted())
}
}
private func validateDescription(_ entries: [AnyStorageKey]) throws {
let missing = entries
.map(\.descriptor)
.filter { $0.description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
.map(\.name)
guard missing.isEmpty else {
throw StorageError.missingDescription(missing.sorted().joined(separator: ", "))
}
}
// MARK: - Serialization
private func serialize<Value: Codable & Sendable>(
_ value: Value,
with serializer: Serializer<Value>
) throws -> Data {
do {
return try serializer.encode(value)
} catch {
throw StorageError.serializationFailed
}
}
private func deserialize<Value: Codable & Sendable>(
_ data: Data,
with serializer: Serializer<Value>
) throws -> Value {
do {
return try serializer.decode(data)
} catch {
throw StorageError.deserializationFailed
}
}
// MARK: - Security
private func applySecurity(
_ data: Data,
for descriptor: StorageKeyDescriptor,
isEncrypt: Bool
) async throws -> Data {
switch descriptor.security {
case .none:
return data
case .encrypted(let encryptionPolicy):
await EncryptionHelper.shared.updateKeychainHelper(keychain)
if isEncrypt {
return try await EncryptionHelper.shared.encrypt(
data,
keyName: descriptor.name,
policy: encryptionPolicy
)
} else {
return try await EncryptionHelper.shared.decrypt(
data,
keyName: descriptor.name,
policy: encryptionPolicy
)
}
case .keychain:
// Keychain security is handled in store/retrieve
return data
}
}
// MARK: - Storage Operations
private func store(_ data: Data, for key: any StorageKey) async throws {
try await store(data, for: .from(key))
}
private func store(_ data: Data, for descriptor: StorageKeyDescriptor) async throws {
switch descriptor.domain {
case .userDefaults(let suite):
try await UserDefaultsHelper.shared.set(data, forKey: descriptor.name, suite: suite)
case .appGroupUserDefaults(let identifier):
let resolvedId = try resolveIdentifier(identifier)
try await UserDefaultsHelper.shared.set(data, forKey: descriptor.name, appGroupIdentifier: resolvedId)
case .keychain(let service):
guard case let .keychain(accessibility, accessControl) = descriptor.security else {
throw StorageError.securityApplicationFailed
}
let resolvedService = try resolveService(service)
try await keychain.set(
data,
service: resolvedService,
key: descriptor.name,
accessibility: accessibility,
accessControl: accessControl
)
case .fileSystem(let directory):
try await FileStorageHelper.shared.write(
data,
to: directory,
fileName: descriptor.name,
useCompleteFileProtection: false
)
case .encryptedFileSystem(let directory):
try await FileStorageHelper.shared.write(
data,
to: directory,
fileName: descriptor.name,
useCompleteFileProtection: true
)
case .appGroupFileSystem(let identifier, let directory):
let resolvedId = try resolveIdentifier(identifier)
try await FileStorageHelper.shared.write(
data,
to: directory,
fileName: descriptor.name,
appGroupIdentifier: resolvedId,
useCompleteFileProtection: false
)
}
}
private func retrieve(for descriptor: StorageKeyDescriptor) async throws -> Data? {
switch descriptor.domain {
case .userDefaults(let suite):
return try await UserDefaultsHelper.shared.get(forKey: descriptor.name, suite: suite)
case .appGroupUserDefaults(let identifier):
let resolvedId = try resolveIdentifier(identifier)
return try await UserDefaultsHelper.shared.get(forKey: descriptor.name, appGroupIdentifier: resolvedId)
case .keychain(let service):
let resolvedService = try resolveService(service)
return try await keychain.get(service: resolvedService, key: descriptor.name)
case .fileSystem(let directory), .encryptedFileSystem(let directory):
return try await FileStorageHelper.shared.read(from: directory, fileName: descriptor.name)
case .appGroupFileSystem(let identifier, let directory):
let resolvedId = try resolveIdentifier(identifier)
return try await FileStorageHelper.shared.read(
from: directory,
fileName: descriptor.name,
appGroupIdentifier: resolvedId
)
}
}
private func delete(for descriptor: StorageKeyDescriptor) async throws {
switch descriptor.domain {
case .userDefaults(let suite):
try await UserDefaultsHelper.shared.remove(forKey: descriptor.name, suite: suite)
case .appGroupUserDefaults(let identifier):
let resolvedId = try resolveIdentifier(identifier)
try await UserDefaultsHelper.shared.remove(forKey: descriptor.name, appGroupIdentifier: resolvedId)
case .keychain(let service):
let resolvedService = try resolveService(service)
try await keychain.delete(service: resolvedService, key: descriptor.name)
case .fileSystem(let directory), .encryptedFileSystem(let directory):
try await FileStorageHelper.shared.delete(from: directory, fileName: descriptor.name)
case .appGroupFileSystem(let identifier, let directory):
let resolvedId = try resolveIdentifier(identifier)
try await FileStorageHelper.shared.delete(
from: directory,
fileName: descriptor.name,
appGroupIdentifier: resolvedId
)
}
}
// MARK: - Sync
private func handleSync(_ key: any StorageKey, data: Data) async throws {
try await SyncHelper.shared.syncIfNeeded(
data: data,
keyName: key.name,
availability: key.availability,
syncPolicy: key.syncPolicy
)
}
// MARK: - Internal Sync Handling
/// Internal method to update storage from received sync data.
/// This is called by SyncHelper when the paired device sends new context.
func updateFromSync(keyName: String, data: Data) async throws {
// Find the registered entry for this key
guard let entry = registeredEntries.first(where: { $0.descriptor.name == keyName }) else {
Logger.debug("Received sync data for unregistered or uncatalogued key: \(keyName)")
return
}
// The data received is already 'secured' (encrypted if necessary) by the sender.
// We can store it directly in our local domain.
try await store(data, for: entry.descriptor)
Logger.info("Successfully updated local storage from sync for key: \(keyName)")
}
// MARK: - Resolution Helpers
private func resolveService(_ service: String?) throws -> String {
guard let resolved = service ?? storageConfiguration.defaultKeychainService else {
Logger.error("No keychain service provided and no default configured")
throw StorageError.keychainError(errSecBadReq) // Or a more specific error
}
Logger.debug("Resolved Keychain Service: \(resolved)")
return resolved
}
private func resolveIdentifier(_ identifier: String?) throws -> String {
guard let resolved = identifier ?? storageConfiguration.defaultAppGroupIdentifier else {
Logger.error("No App Group identifier provided and no default configured")
throw StorageError.invalidAppGroupIdentifier("none")
}
Logger.debug("Resolved App Group ID: \(resolved)")
return resolved
}
}