From bd793619c4f5f39bc6d6eccaf0acc7ee5a2bc62e Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Sat, 17 Jan 2026 11:39:00 -0600 Subject: [PATCH] Update Audit, Configuration, Helpers (+3 more) Summary: - Sources: update Audit, Configuration, Helpers (+3 more) Stats: - 14 files changed, 290 insertions(+), 17 deletions(-) --- .../LocalData/Audit/StorageAuditReport.swift | 7 + .../EncryptionConfiguration.swift | 14 ++ .../FileStorageConfiguration.swift | 14 +- .../Configuration/StorageConfiguration.swift | 8 +- .../Configuration/SyncConfiguration.swift | 4 + .../LocalData/Helpers/EncryptionHelper.swift | 28 +++- .../LocalData/Helpers/FileStorageHelper.swift | 46 +++++- .../LocalData/Helpers/KeychainHelper.swift | 8 + Sources/LocalData/Helpers/SyncHelper.swift | 17 ++- .../Helpers/UserDefaultsHelper.swift | 13 ++ Sources/LocalData/Models/AnyStorageKey.swift | 6 + .../Models/StorageKeyDescriptor.swift | 1 + .../LocalData/Services/StorageRouter.swift | 140 +++++++++++++++++- Sources/LocalData/Utilities/DeviceInfo.swift | 1 + 14 files changed, 290 insertions(+), 17 deletions(-) diff --git a/Sources/LocalData/Audit/StorageAuditReport.swift b/Sources/LocalData/Audit/StorageAuditReport.swift index 99aa7ac..1089b2e 100644 --- a/Sources/LocalData/Audit/StorageAuditReport.swift +++ b/Sources/LocalData/Audit/StorageAuditReport.swift @@ -77,6 +77,7 @@ public struct StorageAuditReport: Sendable { return lines.joined(separator: "\n") } + /// Formats a storage domain for audit output. private static func string(for domain: StorageDomain) -> String { switch domain { case .userDefaults(let suite): @@ -94,6 +95,7 @@ public struct StorageAuditReport: Sendable { } } + /// Formats a file directory for audit output. private static func string(for directory: FileDirectory) -> String { switch directory { case .documents: @@ -105,6 +107,7 @@ public struct StorageAuditReport: Sendable { } } + /// Formats platform availability for audit output. private static func string(for availability: PlatformAvailability) -> String { switch availability { case .all: @@ -118,6 +121,7 @@ public struct StorageAuditReport: Sendable { } } + /// Formats sync policy for audit output. private static func string(for syncPolicy: SyncPolicy) -> String { switch syncPolicy { case .never: @@ -129,6 +133,7 @@ public struct StorageAuditReport: Sendable { } } + /// Formats security policy for audit output. private static func string(for security: SecurityPolicy) -> String { switch security { case .none: @@ -141,6 +146,7 @@ public struct StorageAuditReport: Sendable { } } + /// Formats encryption policy for audit output. private static func string(for policy: SecurityPolicy.EncryptionPolicy) -> String { switch policy { case .aes256(let derivation): @@ -152,6 +158,7 @@ public struct StorageAuditReport: Sendable { } } + /// Formats key derivation for audit output. private static func string(for derivation: SecurityPolicy.KeyDerivation) -> String { switch derivation { case .pbkdf2(let iterations, _): diff --git a/Sources/LocalData/Configuration/EncryptionConfiguration.swift b/Sources/LocalData/Configuration/EncryptionConfiguration.swift index e00f036..b9972cb 100644 --- a/Sources/LocalData/Configuration/EncryptionConfiguration.swift +++ b/Sources/LocalData/Configuration/EncryptionConfiguration.swift @@ -1,6 +1,10 @@ import Foundation /// Configuration for the encryption system. +/// +/// These values control how LocalData derives encryption keys and where the +/// master key is stored. Changing them in a shipped app can make previously +/// encrypted data unreadable, so treat updates as migrations. public struct EncryptionConfiguration: Sendable { /// Keychain service for the master key. public let masterKeyService: String @@ -14,6 +18,16 @@ public struct EncryptionConfiguration: Sendable { public let pbkdf2Iterations: Int /// Creates an encryption configuration. + /// + /// - Parameters: + /// - masterKeyService: Keychain service identifier for the master key. + /// - masterKeyAccount: Keychain account name for the master key. + /// - masterKeyLength: Length of the derived key in bytes. + /// - defaultHKDFInfo: Default HKDF info value used in key derivation. + /// - pbkdf2Iterations: Default PBKDF2 iteration count. + /// + /// - Warning: Changing these values after data is encrypted will cause + /// existing encrypted data to become inaccessible unless you migrate it. public init( masterKeyService: String = "LocalData", masterKeyAccount: String = "MasterKey", diff --git a/Sources/LocalData/Configuration/FileStorageConfiguration.swift b/Sources/LocalData/Configuration/FileStorageConfiguration.swift index 8351212..196743b 100644 --- a/Sources/LocalData/Configuration/FileStorageConfiguration.swift +++ b/Sources/LocalData/Configuration/FileStorageConfiguration.swift @@ -1,16 +1,26 @@ import Foundation -/// Configuration for the FileStorageHelper. +/// Configuration for file-based storage. +/// +/// Controls where LocalData stores files and how it scopes directories for +/// app sandbox and App Group containers. public struct FileStorageConfiguration: Sendable { /// An optional sub-directory to scope all library files within. - /// If provided, files will be stored in `.../Documents/{subDirectory}/` instead of `.../Documents/`. + /// + /// If provided, files will be stored in `.../Documents/{subDirectory}/` + /// instead of `.../Documents/`. public let subDirectory: String? /// An optional base URL to override the default system directories. + /// /// Primarily used for testing isolation. public let baseURL: URL? /// Creates a file storage configuration. + /// + /// - Parameters: + /// - subDirectory: Optional subdirectory for all storage. + /// - baseURL: Optional base URL override for testing. public init(subDirectory: String? = nil, baseURL: URL? = nil) { self.subDirectory = subDirectory self.baseURL = baseURL diff --git a/Sources/LocalData/Configuration/StorageConfiguration.swift b/Sources/LocalData/Configuration/StorageConfiguration.swift index 8c92cfa..e4e607a 100644 --- a/Sources/LocalData/Configuration/StorageConfiguration.swift +++ b/Sources/LocalData/Configuration/StorageConfiguration.swift @@ -1,7 +1,9 @@ import Foundation /// Global configuration for the storage engine. -/// Allows setting default identifiers for Keychain services and App Groups. +/// +/// Allows setting default identifiers for Keychain services and App Groups to +/// reduce repeated configuration in individual keys. public struct StorageConfiguration: Sendable { /// The default Keychain service to use if none is specified in a StorageKey. public let defaultKeychainService: String? @@ -10,6 +12,10 @@ public struct StorageConfiguration: Sendable { public let defaultAppGroupIdentifier: String? /// Creates a configuration with optional defaults. + /// + /// - Parameters: + /// - defaultKeychainService: Default Keychain service identifier. + /// - defaultAppGroupIdentifier: Default App Group identifier. public init( defaultKeychainService: String? = nil, defaultAppGroupIdentifier: String? = nil diff --git a/Sources/LocalData/Configuration/SyncConfiguration.swift b/Sources/LocalData/Configuration/SyncConfiguration.swift index 7f3a1b1..8a93541 100644 --- a/Sources/LocalData/Configuration/SyncConfiguration.swift +++ b/Sources/LocalData/Configuration/SyncConfiguration.swift @@ -1,11 +1,15 @@ import Foundation /// Configuration for WatchConnectivity sync operations. +/// +/// Controls payload limits for automatic sync. public struct SyncConfiguration: Sendable { /// Maximum data size for automatic sync in bytes. public let maxAutoSyncSize: Int /// Creates a sync configuration. + /// + /// - Parameter maxAutoSyncSize: Maximum payload size in bytes. public init(maxAutoSyncSize: Int = 100_000) { self.maxAutoSyncSize = maxAutoSyncSize } diff --git a/Sources/LocalData/Helpers/EncryptionHelper.swift b/Sources/LocalData/Helpers/EncryptionHelper.swift index 9e0de5b..7abf42f 100644 --- a/Sources/LocalData/Helpers/EncryptionHelper.swift +++ b/Sources/LocalData/Helpers/EncryptionHelper.swift @@ -3,14 +3,20 @@ import CryptoKit /// Actor that handles all encryption and decryption operations. /// -/// Uses AES-GCM or ChaChaPoly for symmetric encryption with derived keys. +/// Uses AES-GCM or ChaChaPoly for symmetric encryption with derived keys, and +/// stores a master key in Keychain for deterministic derivation. actor EncryptionHelper { /// Shared encryption helper instance. + /// + /// Prefer this shared instance in production code. Tests can inject a custom instance. public static let shared = EncryptionHelper() + /// Current encryption configuration. private var configuration: EncryptionConfiguration + /// Keychain provider used for master key storage. private var keychain: KeychainStoring + /// External key material providers keyed by source identifier. private var keyMaterialProviders: [KeyMaterialSource: any KeyMaterialProviding] = [:] /// Creates an encryption helper with a configuration and keychain provider. @@ -58,12 +64,13 @@ actor EncryptionHelper { } /// Encrypts data using AES-GCM or ChaChaPoly. + /// /// - Parameters: /// - data: The plaintext data to encrypt. /// - keyName: A unique name used for key derivation salt. /// - policy: The encryption policy specifying algorithm and key derivation. /// - Returns: The encrypted data (nonce + ciphertext + tag combined). - /// - Throws: `StorageError.securityApplicationFailed` if encryption fails. + /// - Throws: ``StorageError/securityApplicationFailed`` if encryption fails. public func encrypt( _ data: Data, keyName: String, @@ -74,12 +81,13 @@ actor EncryptionHelper { } /// Decrypts data using AES-GCM or ChaChaPoly. + /// /// - Parameters: /// - data: The encrypted data (nonce + ciphertext + tag combined). /// - keyName: The same unique name used during encryption. /// - policy: The same encryption policy used during encryption. /// - Returns: The decrypted plaintext data. - /// - Throws: `StorageError.securityApplicationFailed` if decryption fails. + /// - Throws: ``StorageError/securityApplicationFailed`` if decryption fails. public func decrypt( _ data: Data, keyName: String, @@ -92,6 +100,7 @@ actor EncryptionHelper { // MARK: - Key Derivation /// Derives an encryption key using the specified policy. + /// Derives a symmetric key based on the encryption policy. private func deriveKey( keyName: String, policy: SecurityPolicy.EncryptionPolicy @@ -119,6 +128,7 @@ actor EncryptionHelper { } /// Derives key material based on the provided key derivation strategy. + /// Derives key material using HKDF or PBKDF2. private func deriveKeyMaterial( keyName: String, derivation: SecurityPolicy.KeyDerivation, @@ -149,6 +159,7 @@ actor EncryptionHelper { } /// Gets or creates the master key stored in keychain. + /// Retrieves or creates the master key stored in Keychain. private func getMasterKey() async throws -> Data { if let existing = try await keychain.get( service: configuration.masterKeyService, @@ -180,6 +191,7 @@ actor EncryptionHelper { // MARK: - AES-GCM Operations + /// Encrypts data using the selected algorithm and key. private func encryptWithKey( _ data: Data, using key: SymmetricKey, @@ -195,6 +207,7 @@ actor EncryptionHelper { } } + /// Encrypts data using AES-GCM. private func encryptWithAESGCM(_ data: Data, using key: SymmetricKey) throws -> Data { do { let sealedBox = try AES.GCM.seal(data, using: key) @@ -207,6 +220,7 @@ actor EncryptionHelper { } } + /// Decrypts data using the selected algorithm and key. private func decryptWithKey( _ data: Data, using key: SymmetricKey, @@ -222,6 +236,7 @@ actor EncryptionHelper { } } + /// Decrypts data using AES-GCM. private func decryptWithAESGCM(_ data: Data, using key: SymmetricKey) throws -> Data { do { let sealedBox = try AES.GCM.SealedBox(combined: data) @@ -231,6 +246,7 @@ actor EncryptionHelper { } } + /// Encrypts data using ChaChaPoly. private func encryptWithChaChaPoly(_ data: Data, using key: SymmetricKey) throws -> Data { do { let sealedBox = try ChaChaPoly.seal(data, using: key) @@ -240,6 +256,7 @@ actor EncryptionHelper { } } + /// Decrypts data using ChaChaPoly. private func decryptWithChaChaPoly(_ data: Data, using key: SymmetricKey) throws -> Data { do { let sealedBox = try ChaChaPoly.SealedBox(combined: data) @@ -251,6 +268,7 @@ actor EncryptionHelper { // MARK: - PBKDF2 Implementation + /// Derives key data using PBKDF2-SHA256. private func pbkdf2SHA256( password: Data, salt: Data, @@ -286,22 +304,26 @@ actor EncryptionHelper { return derivedKey.prefix(keyLength) } + /// Computes HMAC-SHA256 for PBKDF2. private func hmacSHA256(key: Data, data: Data) -> Data { let symmetricKey = SymmetricKey(data: key) let mac = HMAC.authenticationCode(for: data, using: symmetricKey) return Data(mac) } + /// XORs two data buffers for PBKDF2 chaining. private func xor(_ left: Data, _ right: Data) -> Data { let xored = zip(left, right).map { $0 ^ $1 } return Data(xored) } + /// Encodes a UInt32 as big-endian data. private func uint32BigEndian(_ value: UInt32) -> Data { var bigEndian = value.bigEndian return Data(bytes: &bigEndian, count: MemoryLayout.size) } + /// Generates a deterministic salt from a key name. private func defaultSalt(for keyName: String) -> Data { Data(keyName.utf8) } diff --git a/Sources/LocalData/Helpers/FileStorageHelper.swift b/Sources/LocalData/Helpers/FileStorageHelper.swift index 1460316..6368e04 100644 --- a/Sources/LocalData/Helpers/FileStorageHelper.swift +++ b/Sources/LocalData/Helpers/FileStorageHelper.swift @@ -3,12 +3,15 @@ import Foundation /// Actor that handles all file system operations. /// /// Provides thread-safe file reading, writing, deletion, and listing for -/// app sandbox and App Group containers. +/// app sandbox and App Group containers, with optional subdirectory scoping. actor FileStorageHelper { /// Shared file storage helper instance. + /// + /// Prefer this shared instance in production code. Tests can inject a custom instance. public static let shared = FileStorageHelper() + /// Current file storage configuration. private var configuration: FileStorageConfiguration /// Creates a helper with a specific configuration. @@ -241,6 +244,10 @@ actor FileStorageHelper { // MARK: - Private Helpers + /// Ensures the parent directory exists before writing a file. + /// + /// - Parameter url: Directory URL to create if missing. + /// - Throws: ``StorageError/fileError(_:)`` if creation fails. private func ensureDirectoryExists(at url: URL) throws { guard !FileManager.default.fileExists(atPath: url.path) else { return @@ -259,6 +266,13 @@ actor FileStorageHelper { } } + /// Writes data to a URL with optional file protection. + /// + /// - Parameters: + /// - data: The data to write. + /// - url: Destination URL. + /// - useCompleteFileProtection: Whether to apply complete file protection. + /// - Throws: ``StorageError/fileError(_:)`` if write fails. private func write(_ data: Data, to url: URL, useCompleteFileProtection: Bool) throws { var options: Data.WritingOptions = [.atomic] if useCompleteFileProtection { @@ -274,6 +288,11 @@ actor FileStorageHelper { } } + /// Reads data from a URL if it exists. + /// + /// - Parameter url: File URL to read. + /// - Returns: File data if present, otherwise `nil`. + /// - Throws: ``StorageError/fileError(_:)`` if read fails. private func read(from url: URL) throws -> Data? { guard FileManager.default.fileExists(atPath: url.path) else { return nil @@ -286,6 +305,10 @@ actor FileStorageHelper { } } + /// Deletes a file if it exists. + /// + /// - Parameter url: File URL to delete. + /// - Throws: ``StorageError/fileError(_:)`` if deletion fails. private func delete(file url: URL) throws { guard FileManager.default.fileExists(atPath: url.path) else { return @@ -298,6 +321,11 @@ actor FileStorageHelper { } } + /// Lists file names in a directory URL. + /// + /// - Parameter url: Directory URL to list. + /// - Returns: File names in the directory. + /// - Throws: ``StorageError/fileError(_:)`` if listing fails. private func list(in url: URL) throws -> [String] { guard FileManager.default.fileExists(atPath: url.path) else { return [] @@ -310,6 +338,11 @@ actor FileStorageHelper { } } + /// Returns the size of a file at a URL. + /// + /// - Parameter url: File URL to measure. + /// - Returns: File size in bytes, or `nil` if missing. + /// - Throws: ``StorageError/fileError(_:)`` if attributes fail. private func size(of url: URL) throws -> Int64? { guard FileManager.default.fileExists(atPath: url.path) else { return nil @@ -323,6 +356,11 @@ actor FileStorageHelper { } } + /// Resolves the App Group container URL or throws if invalid. + /// + /// - Parameter identifier: App Group identifier. + /// - Returns: Container URL for the App Group. + /// - Throws: ``StorageError/invalidAppGroupIdentifier(_:)`` if unresolved. private func appGroupContainerURL(identifier: String) throws -> URL { guard let url = FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: identifier @@ -334,6 +372,12 @@ actor FileStorageHelper { return url } + /// Resolves the final directory URL, applying overrides and subdirectory settings. + /// + /// - Parameters: + /// - overrideURL: Optional base URL override. + /// - directory: Target file directory. + /// - Returns: Resolved directory URL. private func resolveDirectoryURL(baseURL overrideURL: URL? = nil, directory: FileDirectory) throws -> URL { let base: URL // Priority: 1. Method override, 2. Configuration override, 3. System default diff --git a/Sources/LocalData/Helpers/KeychainHelper.swift b/Sources/LocalData/Helpers/KeychainHelper.swift index a74a5d9..46b6917 100644 --- a/Sources/LocalData/Helpers/KeychainHelper.swift +++ b/Sources/LocalData/Helpers/KeychainHelper.swift @@ -8,6 +8,8 @@ import Security actor KeychainHelper: KeychainStoring { /// Shared keychain helper instance. + /// + /// Prefer this shared instance in production code. Tests can inject a custom instance. public static let shared = KeychainHelper() private init() {} @@ -168,6 +170,12 @@ actor KeychainHelper: KeychainStoring { // MARK: - Private Helpers + /// Base keychain query for a service/key pair. + /// + /// - Parameters: + /// - service: Keychain service identifier. + /// - key: Keychain account name. + /// - Returns: Base query dictionary for Security framework calls. private func baseQuery(service: String, key: String) -> [String: Any] { return [ kSecClass as String: kSecClassGenericPassword, diff --git a/Sources/LocalData/Helpers/SyncHelper.swift b/Sources/LocalData/Helpers/SyncHelper.swift index 32ce2de..c05e26a 100644 --- a/Sources/LocalData/Helpers/SyncHelper.swift +++ b/Sources/LocalData/Helpers/SyncHelper.swift @@ -3,12 +3,16 @@ import WatchConnectivity /// Actor that handles WatchConnectivity sync operations. /// -/// Manages data synchronization between iPhone and Apple Watch. +/// Manages data synchronization between iPhone and Apple Watch and provides +/// size- and policy-based gating for outbound sync. actor SyncHelper { /// Shared sync helper instance. + /// + /// Prefer this shared instance in production code. Tests can inject a custom instance. public static let shared = SyncHelper() + /// Current sync configuration. private var configuration: SyncConfiguration /// Creates a helper with a specific configuration. @@ -107,6 +111,12 @@ actor SyncHelper { // MARK: - Private Helpers + /// Sends an application context update if WatchConnectivity is ready. + /// + /// - Parameters: + /// - data: The data payload to sync. + /// - keyName: The key used in the application context dictionary. + /// - Throws: `WCSession` update errors from `updateApplicationContext`. private func performSync(data: Data, keyName: String) throws { guard WCSession.isSupported() else { return } @@ -127,6 +137,7 @@ actor SyncHelper { Logger.info("<<< [SYNC] Application context updated for key: \(keyName). Keys now: [\(contextKeys)]") } + /// Lazily configures and activates WCSession. private func setupSession() { let session = WCSession.default session.delegate = SessionDelegateProxy.shared @@ -134,7 +145,9 @@ actor SyncHelper { } /// Handles received application context from the paired device. - /// This is called by the delegate proxy. + /// + /// - Parameter context: Application context dictionary received from WCSession. + /// - Note: This is called by the delegate proxy. internal func handleReceivedContext(_ context: [String: Any]) async { Logger.info(">>> [SYNC] Received application context with \(context.count) keys") for (key, value) in context { diff --git a/Sources/LocalData/Helpers/UserDefaultsHelper.swift b/Sources/LocalData/Helpers/UserDefaultsHelper.swift index 1a47154..4579079 100644 --- a/Sources/LocalData/Helpers/UserDefaultsHelper.swift +++ b/Sources/LocalData/Helpers/UserDefaultsHelper.swift @@ -6,8 +6,11 @@ import Foundation actor UserDefaultsHelper { /// Shared helper instance. + /// + /// Prefer this shared instance in production code. Tests can inject a custom instance. public static let shared = UserDefaultsHelper() + /// Underlying defaults instance used when no suite is specified. private let defaults: UserDefaults /// Creates a helper with a specific `UserDefaults` instance. @@ -125,6 +128,11 @@ actor UserDefaultsHelper { // MARK: - Private Helpers + /// Resolves the correct `UserDefaults` instance for a suite. + /// + /// - Parameter suite: Suite name, or `nil` to use standard defaults. + /// - Returns: The resolved `UserDefaults` instance. + /// - Throws: ``StorageError/invalidUserDefaultsSuite(_:)`` if the suite is invalid. private func userDefaults(for suite: String?) throws -> UserDefaults { if let suite { guard let suiteDefaults = UserDefaults(suiteName: suite) else { @@ -135,6 +143,11 @@ actor UserDefaultsHelper { return defaults } + /// Resolves App Group `UserDefaults` or throws if invalid. + /// + /// - Parameter identifier: App Group identifier. + /// - Returns: The resolved App Group defaults. + /// - Throws: ``StorageError/invalidAppGroupIdentifier(_:)`` if invalid. private func appGroupDefaults(for identifier: String) throws -> UserDefaults { guard let defaults = UserDefaults(suiteName: identifier) else { throw StorageError.invalidAppGroupIdentifier(identifier) diff --git a/Sources/LocalData/Models/AnyStorageKey.swift b/Sources/LocalData/Models/AnyStorageKey.swift index 747d534..def4262 100644 --- a/Sources/LocalData/Models/AnyStorageKey.swift +++ b/Sources/LocalData/Models/AnyStorageKey.swift @@ -35,6 +35,9 @@ public struct AnyStorageKey: Sendable { } /// Internal use: Returns a copy of this key with the catalog name set. + /// + /// - Parameter name: Catalog name to assign. + /// - Returns: A new type-erased key with the catalog name applied. internal func withCatalog(_ name: String) -> AnyStorageKey { AnyStorageKey( descriptor: descriptor.withCatalog(name), @@ -44,6 +47,9 @@ public struct AnyStorageKey: Sendable { } /// Internal use: Triggers the migration logic for this key. + /// + /// - Parameter router: Router used to perform the migration. + /// - Throws: Migration or storage errors. internal func migrate(on router: StorageRouter) async throws { try await migrateAction(router) } diff --git a/Sources/LocalData/Models/StorageKeyDescriptor.swift b/Sources/LocalData/Models/StorageKeyDescriptor.swift index b90d967..f5af324 100644 --- a/Sources/LocalData/Models/StorageKeyDescriptor.swift +++ b/Sources/LocalData/Models/StorageKeyDescriptor.swift @@ -23,6 +23,7 @@ public struct StorageKeyDescriptor: Sendable, CustomStringConvertible { /// Optional catalog name the key belongs to. public let catalog: String? + /// Internal initializer used by factories and audits. init( name: String, domain: StorageDomain, diff --git a/Sources/LocalData/Services/StorageRouter.swift b/Sources/LocalData/Services/StorageRouter.swift index d6ceecc..052db2c 100644 --- a/Sources/LocalData/Services/StorageRouter.swift +++ b/Sources/LocalData/Services/StorageRouter.swift @@ -1,16 +1,23 @@ import Foundation -/// The main storage router that coordinates all storage operations. -/// Uses specialized helper actors for each storage domain. /// Central coordinator for all LocalData storage operations. /// -/// `StorageRouter` orchestrates serialization, security, storage domain routing, -/// catalog validation, migrations, and WatchConnectivity sync. Use the shared -/// instance for app-wide access and register catalogs at launch to enable -/// auditability and duplicate key detection. +/// `StorageRouter` orchestrates: +/// - serialization and deserialization through ``Serializer`` +/// - security policies through ``SecurityPolicy`` +/// - storage routing across ``StorageDomain`` +/// - catalog validation and audit tracking +/// - migrations and migration history +/// - WatchConnectivity sync coordination +/// +/// Use the shared instance for app-wide access and register catalogs at launch +/// to enable auditability and duplicate key detection. public actor StorageRouter: StorageProviding { /// Shared router instance for app-wide storage access. + /// + /// Prefer this shared instance in production code. For tests, inject a + /// custom instance with isolated helpers. public static let shared = StorageRouter() private var catalogRegistries: [String: [AnyStorageKey]] = [:] @@ -25,8 +32,15 @@ public actor StorageRouter: StorageProviding { /// Initializes a new router with injected helpers. /// + /// - Parameters: + /// - keychain: Keychain helper implementation. + /// - encryption: Encryption helper implementation. + /// - file: File storage helper implementation. + /// - defaults: UserDefaults helper implementation. + /// - sync: Sync helper implementation. + /// /// - Important: Internal for testing isolation via `@testable import`. - /// Production code should use ``StorageRouter/shared``. + /// Production code should use ``StorageRouter/shared``. internal init( keychain: KeychainStoring = KeychainHelper.shared, encryption: EncryptionHelper = .shared, @@ -267,6 +281,12 @@ public actor StorageRouter: StorageProviding { } } + /// Checks existence using a descriptor (used by migrations and audits). + /// Checks whether data exists for a descriptor. + /// + /// - Parameter descriptor: Descriptor describing the storage location. + /// - Returns: `true` if data exists, otherwise `false`. + /// - Throws: Storage or configuration errors from the underlying helper. internal func exists(descriptor: StorageKeyDescriptor) async throws -> Bool { switch descriptor.domain { case .userDefaults(let suite): @@ -291,6 +311,11 @@ public actor StorageRouter: StorageProviding { // MARK: - Platform Validation + /// Enforces platform availability rules for a key. + /// + /// - Parameter key: The key being accessed. + /// - Throws: ``StorageError/phoneOnlyKeyAccessedOnWatch(_:)`` or + /// ``StorageError/watchOnlyKeyAccessedOnPhone(_:)``. nonisolated private func validatePlatformAvailability(for key: StorageKey) throws { #if os(watchOS) if key.availability == .phoneOnly { @@ -303,6 +328,10 @@ public actor StorageRouter: StorageProviding { #endif } + /// Validates that a key is present in the registered catalog set. + /// + /// - Parameter key: The key being accessed. + /// - Throws: ``StorageError/unregisteredKey(_:)`` when catalogs are registered and the key is missing. private func validateCatalogRegistration(for key: StorageKey) throws { guard !registeredKeys.isEmpty else { return } guard registeredKeys[key.name] != nil else { @@ -317,6 +346,9 @@ public actor StorageRouter: StorageProviding { } } + /// Detects test environments to avoid noisy assertions in tests. + /// + /// This is a best-effort check using environment variables and XCTest classes. private var isRunningTests: Bool { // Broad check for any test-related environment variables or classes if ProcessInfo.processInfo.environment.keys.contains(where: { @@ -327,6 +359,10 @@ public actor StorageRouter: StorageProviding { return NSClassFromString("XCTestCase") != nil || NSClassFromString("Testing.Test") != nil } + /// Ensures no duplicate key names exist within a catalog. + /// + /// - Parameter entries: The keys being registered. + /// - Throws: ``StorageError/duplicateRegisteredKeys(_:)`` when duplicates are found. private func validateUniqueKeys(_ entries: [AnyStorageKey]) throws { var exactNames: [String: Int] = [:] var duplicates: [String] = [] @@ -344,6 +380,10 @@ public actor StorageRouter: StorageProviding { } } + /// Ensures all keys have non-empty descriptions for audits. + /// + /// - Parameter entries: The keys being registered. + /// - Throws: ``StorageError/missingDescription(_:)`` when descriptions are empty. private func validateDescription(_ entries: [AnyStorageKey]) throws { let missing = entries .map(\.descriptor) @@ -357,6 +397,11 @@ public actor StorageRouter: StorageProviding { // MARK: - Migration + /// Attempts a migration and returns the migrated value if successful. + /// + /// - Parameter key: Destination key for migration. + /// - Returns: The migrated value when migration succeeds. + /// - Throws: ``MigrationError`` if the migration reports an error. private func attemptMigration(for key: StorageKey) async throws -> Value? { guard let migration = resolveMigration(for: key) else { return nil } @@ -380,18 +425,34 @@ public actor StorageRouter: StorageProviding { return nil } + /// Resolves the migration attached to a key, if any. + /// + /// - Parameter key: Key that may contain a migration builder. + /// - Returns: The resolved type-erased migration, or `nil`. private func resolveMigration(for key: StorageKey) -> AnyStorageMigration? { key.migration } + /// Builds a migration context with the router's current history. + /// + /// - Returns: A new ``MigrationContext`` populated with history. internal func buildMigrationContext() -> MigrationContext { MigrationContext(migrationHistory: migrationHistory) } + /// Records a successful migration timestamp for a key. + /// + /// - Parameter descriptor: Descriptor of the migrated key. internal func recordMigration(for descriptor: StorageKeyDescriptor) { migrationHistory[descriptor.name] = Date() } + /// Evaluates platform and sync prerequisites for migration. + /// + /// - Parameters: + /// - key: Destination key to migrate into. + /// - context: Migration context describing the environment. + /// - Returns: `true` if migration is allowed. internal func shouldAllowMigration( for key: StorageKey, context: MigrationContext @@ -409,6 +470,13 @@ public actor StorageRouter: StorageProviding { // MARK: - Serialization + /// Encodes a value to data using the provided serializer. + /// + /// - Parameters: + /// - value: The value to encode. + /// - serializer: Serializer to use. + /// - Returns: Encoded `Data`. + /// - Throws: ``StorageError/serializationFailed``. private func serialize( _ value: Value, with serializer: Serializer @@ -420,6 +488,13 @@ public actor StorageRouter: StorageProviding { } } + /// Decodes data to a value using the provided serializer. + /// + /// - Parameters: + /// - data: The data to decode. + /// - serializer: Serializer to use. + /// - Returns: Decoded value. + /// - Throws: ``StorageError/deserializationFailed``. nonisolated internal func deserialize( _ data: Data, with serializer: Serializer @@ -433,6 +508,14 @@ public actor StorageRouter: StorageProviding { // MARK: - Security + /// Applies encryption or decryption based on the descriptor's policy. + /// + /// - Parameters: + /// - data: The data to secure or unsecure. + /// - descriptor: Descriptor describing the security policy. + /// - isEncrypt: `true` to encrypt, `false` to decrypt. + /// - Returns: Secured or unsecured data. + /// - Throws: ``StorageError/securityApplicationFailed`` if crypto fails. internal func applySecurity( _ data: Data, for descriptor: StorageKeyDescriptor, @@ -466,10 +549,21 @@ public actor StorageRouter: StorageProviding { // MARK: - Storage Operations + /// Stores secured data for a typed key. + /// + /// - Parameters: + /// - data: The secured data to store. + /// - key: The destination key. private func store(_ data: Data, for key: StorageKey) async throws { try await store(data, for: .from(key)) } + /// Stores secured data for a key descriptor. + /// + /// - Parameters: + /// - data: The secured data to store. + /// - descriptor: The storage descriptor to use. + /// - Throws: Storage or configuration errors. private func store(_ data: Data, for descriptor: StorageKeyDescriptor) async throws { switch descriptor.domain { case .userDefaults(let suite): @@ -520,6 +614,11 @@ public actor StorageRouter: StorageProviding { } } + /// Retrieves secured data for a key descriptor. + /// + /// - Parameter descriptor: The storage descriptor to read. + /// - Returns: Secured data if found, otherwise `nil`. + /// - Throws: Storage or configuration errors. internal func retrieve(for descriptor: StorageKeyDescriptor) async throws -> Data? { switch descriptor.domain { case .userDefaults(let suite): @@ -544,6 +643,10 @@ public actor StorageRouter: StorageProviding { } } + /// Deletes data for a key descriptor. + /// + /// - Parameter descriptor: The storage descriptor to delete. + /// - Throws: Storage or configuration errors. internal func delete(for descriptor: StorageKeyDescriptor) async throws { switch descriptor.domain { case .userDefaults(let suite): @@ -570,6 +673,12 @@ public actor StorageRouter: StorageProviding { // MARK: - Sync + /// Performs sync if the key's policy and availability allow it. + /// + /// - Parameters: + /// - key: The key being synced. + /// - data: The secured data payload. + /// - Throws: ``StorageError/dataTooLargeForSync`` when automatic sync exceeds limits. private func handleSync(_ key: StorageKey, data: Data) async throws { try await sync.syncIfNeeded( data: data, @@ -663,7 +772,12 @@ public actor StorageRouter: StorageProviding { // 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. + /// + /// Called by ``SyncHelper`` when the paired device sends new context. + /// - Parameters: + /// - keyName: The storage key name. + /// - data: The secured data payload. + /// - Throws: Storage errors during write. func updateFromSync(keyName: String, data: Data) async throws { // Find the registered entry for this key guard let entry = registeredKeys[keyName] else { @@ -679,6 +793,11 @@ public actor StorageRouter: StorageProviding { // MARK: - Resolution Helpers + /// Resolves a keychain service using key defaults when needed. + /// + /// - Parameter service: Explicit service, or `nil` to use defaults. + /// - Returns: Resolved service identifier. + /// - Throws: ``StorageError/keychainError(_:)`` when no service is available. private func resolveService(_ service: String?) throws -> String { guard let resolved = service ?? storageConfiguration.defaultKeychainService else { Logger.error("No keychain service provided and no default configured") @@ -688,6 +807,11 @@ public actor StorageRouter: StorageProviding { return resolved } + /// Resolves an App Group identifier using defaults when needed. + /// + /// - Parameter identifier: Explicit identifier, or `nil` to use defaults. + /// - Returns: Resolved App Group identifier. + /// - Throws: ``StorageError/invalidAppGroupIdentifier(_:)`` when no identifier is available. 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") diff --git a/Sources/LocalData/Utilities/DeviceInfo.swift b/Sources/LocalData/Utilities/DeviceInfo.swift index af594b3..018569c 100644 --- a/Sources/LocalData/Utilities/DeviceInfo.swift +++ b/Sources/LocalData/Utilities/DeviceInfo.swift @@ -21,6 +21,7 @@ public struct DeviceInfo: Sendable { /// Current device info derived from the running environment. public static let current = DeviceInfo() + /// Builds a snapshot of the current device environment. private init() { #if os(iOS) self.platform = .iOS