diff --git a/Sources/LocalData/Audit/StorageAuditReport.swift b/Sources/LocalData/Audit/StorageAuditReport.swift index 1089b2e..8a831a3 100644 --- a/Sources/LocalData/Audit/StorageAuditReport.swift +++ b/Sources/LocalData/Audit/StorageAuditReport.swift @@ -1,9 +1,16 @@ import Foundation /// Renders audit reports for storage key catalogs and registries. +/// +/// Use this type to build human-readable snapshots of your storage surface area. +/// Reports are useful for security reviews, compliance audits, and debugging +/// catalog registration issues. public struct StorageAuditReport: Sendable { /// Returns descriptors for all keys in a catalog. /// + /// This applies the catalog name to each descriptor, so the output can be + /// grouped and traced back to its module. + /// /// - Parameter catalog: Catalog containing keys to describe. /// - Returns: An array of ``StorageKeyDescriptor`` values. public static func items(for catalog: some StorageKeyCatalog) -> [StorageKeyDescriptor] { @@ -20,6 +27,9 @@ public struct StorageAuditReport: Sendable { /// Renders a text report for a list of type-erased keys. /// + /// Use this when you already have `AnyStorageKey` entries, such as from + /// `StorageRouter.allRegisteredEntries()`. + /// /// - Parameter entries: The keys to render. /// - Returns: A newline-delimited report string. public static func renderText(_ entries: [AnyStorageKey]) -> String { @@ -36,6 +46,9 @@ public struct StorageAuditReport: Sendable { /// Renders a text report for the global registry grouped by catalog. /// + /// Each catalog section is prefixed with a header and followed by its + /// contained key descriptors. + /// /// - Returns: A report string grouped by catalog name. public static func renderGlobalRegistryGrouped() async -> String { let catalogs = await StorageRouter.shared.allRegisteredCatalogs() @@ -78,6 +91,9 @@ public struct StorageAuditReport: Sendable { } /// Formats a storage domain for audit output. + /// + /// - Parameter domain: Domain to format. + /// - Returns: A concise, human-readable domain string. private static func string(for domain: StorageDomain) -> String { switch domain { case .userDefaults(let suite): @@ -96,6 +112,9 @@ public struct StorageAuditReport: Sendable { } /// Formats a file directory for audit output. + /// + /// - Parameter directory: Directory to format. + /// - Returns: A concise directory string. private static func string(for directory: FileDirectory) -> String { switch directory { case .documents: @@ -108,6 +127,9 @@ public struct StorageAuditReport: Sendable { } /// Formats platform availability for audit output. + /// + /// - Parameter availability: Availability to format. + /// - Returns: A concise availability string. private static func string(for availability: PlatformAvailability) -> String { switch availability { case .all: @@ -122,6 +144,9 @@ public struct StorageAuditReport: Sendable { } /// Formats sync policy for audit output. + /// + /// - Parameter syncPolicy: Sync policy to format. + /// - Returns: A concise sync policy string. private static func string(for syncPolicy: SyncPolicy) -> String { switch syncPolicy { case .never: @@ -134,6 +159,9 @@ public struct StorageAuditReport: Sendable { } /// Formats security policy for audit output. + /// + /// - Parameter security: Security policy to format. + /// - Returns: A concise security policy string. private static func string(for security: SecurityPolicy) -> String { switch security { case .none: @@ -147,6 +175,9 @@ public struct StorageAuditReport: Sendable { } /// Formats encryption policy for audit output. + /// + /// - Parameter policy: Encryption policy to format. + /// - Returns: A concise encryption policy string. private static func string(for policy: SecurityPolicy.EncryptionPolicy) -> String { switch policy { case .aes256(let derivation): @@ -159,6 +190,9 @@ public struct StorageAuditReport: Sendable { } /// Formats key derivation for audit output. + /// + /// - Parameter derivation: Derivation to format. + /// - Returns: A concise derivation string. private static func string(for derivation: SecurityPolicy.KeyDerivation) -> String { switch derivation { case .pbkdf2(let iterations, _): diff --git a/Sources/LocalData/Helpers/EncryptionHelper.swift b/Sources/LocalData/Helpers/EncryptionHelper.swift index 7abf42f..2529179 100644 --- a/Sources/LocalData/Helpers/EncryptionHelper.swift +++ b/Sources/LocalData/Helpers/EncryptionHelper.swift @@ -3,20 +3,29 @@ import CryptoKit /// Actor that handles all encryption and decryption operations. /// -/// Uses AES-GCM or ChaChaPoly for symmetric encryption with derived keys, and -/// stores a master key in Keychain for deterministic derivation. +/// `EncryptionHelper` provides symmetric encryption using AES-GCM or +/// ChaChaPoly. It derives per-key symmetric keys from a master key stored in +/// Keychain (or from external key material when configured) so encrypted data +/// is deterministic and recoverable across app launches. actor EncryptionHelper { /// Shared encryption helper instance. /// - /// Prefer this shared instance in production code. Tests can inject a custom instance. + /// Prefer this shared instance in production code. Tests can inject a custom instance + /// with isolated configuration and keychain dependencies. public static let shared = EncryptionHelper() /// Current encryption configuration. + /// + /// Controls key derivation defaults and master key settings. private var configuration: EncryptionConfiguration /// Keychain provider used for master key storage. + /// + /// Abstracted to allow test stubs and isolation. private var keychain: KeychainStoring /// External key material providers keyed by source identifier. + /// + /// Used for ``SecurityPolicy/EncryptionPolicy/external(source:keyDerivation:)``. private var keyMaterialProviders: [KeyMaterialSource: any KeyMaterialProviding] = [:] /// Creates an encryption helper with a configuration and keychain provider. @@ -37,6 +46,8 @@ actor EncryptionHelper { /// Updates the configuration for the actor. /// /// - Parameter configuration: New encryption configuration. + /// - Warning: Changing these values after data is encrypted may make + /// existing data unreadable unless migrated. public func updateConfiguration(_ configuration: EncryptionConfiguration) { self.configuration = configuration } @@ -44,7 +55,7 @@ actor EncryptionHelper { /// Updates the keychain helper used for master key storage. /// /// - Parameter keychain: Keychain provider to use. - /// - Note: Internal for testing isolation. + /// - Note: Intended for testing isolation and dependency injection. public func updateKeychainHelper(_ keychain: KeychainStoring) { self.keychain = keychain } @@ -56,6 +67,7 @@ actor EncryptionHelper { /// - Parameters: /// - provider: The provider that supplies key material. /// - source: Identifier used to look up the provider. + /// - Note: Required when using ``SecurityPolicy/EncryptionPolicy/external(source:keyDerivation:)``. public func registerKeyMaterialProvider( _ provider: any KeyMaterialProviding, for source: KeyMaterialSource @@ -99,8 +111,13 @@ actor EncryptionHelper { // MARK: - Key Derivation - /// Derives an encryption key using the specified policy. /// Derives a symmetric key based on the encryption policy. + /// + /// - Parameters: + /// - keyName: Logical key name used to seed derivation. + /// - policy: Encryption policy describing algorithm and derivation. + /// - Returns: A symmetric key for encryption/decryption. + /// - Throws: ``StorageError/securityApplicationFailed`` when derivation fails or no provider exists. private func deriveKey( keyName: String, policy: SecurityPolicy.EncryptionPolicy @@ -127,8 +144,14 @@ actor EncryptionHelper { } } - /// Derives key material based on the provided key derivation strategy. /// Derives key material using HKDF or PBKDF2. + /// + /// - Parameters: + /// - keyName: Logical key name used to seed derivation. + /// - derivation: Key derivation strategy. + /// - baseKeyMaterial: Base key material (master key or external). + /// - Returns: A symmetric key derived from the inputs. + /// - Throws: ``StorageError/securityApplicationFailed`` if derivation fails. private func deriveKeyMaterial( keyName: String, derivation: SecurityPolicy.KeyDerivation, @@ -158,8 +181,10 @@ actor EncryptionHelper { } } - /// Gets or creates the master key stored in keychain. /// Retrieves or creates the master key stored in Keychain. + /// + /// - Returns: The master key data. + /// - Throws: ``StorageError/securityApplicationFailed`` or keychain errors. private func getMasterKey() async throws -> Data { if let existing = try await keychain.get( service: configuration.masterKeyService, @@ -192,6 +217,13 @@ actor EncryptionHelper { // MARK: - AES-GCM Operations /// Encrypts data using the selected algorithm and key. + /// + /// - Parameters: + /// - data: Plaintext data to encrypt. + /// - key: Symmetric key to use. + /// - policy: Encryption policy selecting the algorithm. + /// - Returns: Combined ciphertext for storage. + /// - Throws: ``StorageError/securityApplicationFailed`` if encryption fails. private func encryptWithKey( _ data: Data, using key: SymmetricKey, @@ -208,6 +240,12 @@ actor EncryptionHelper { } /// Encrypts data using AES-GCM. + /// + /// - Parameters: + /// - data: Plaintext data to encrypt. + /// - key: Symmetric key to use. + /// - Returns: Combined nonce + ciphertext + tag. + /// - Throws: ``StorageError/securityApplicationFailed`` if encryption fails. private func encryptWithAESGCM(_ data: Data, using key: SymmetricKey) throws -> Data { do { let sealedBox = try AES.GCM.seal(data, using: key) @@ -221,6 +259,13 @@ actor EncryptionHelper { } /// Decrypts data using the selected algorithm and key. + /// + /// - Parameters: + /// - data: Combined nonce + ciphertext + tag. + /// - key: Symmetric key to use. + /// - policy: Encryption policy selecting the algorithm. + /// - Returns: Decrypted plaintext data. + /// - Throws: ``StorageError/securityApplicationFailed`` if decryption fails. private func decryptWithKey( _ data: Data, using key: SymmetricKey, @@ -237,6 +282,12 @@ actor EncryptionHelper { } /// Decrypts data using AES-GCM. + /// + /// - Parameters: + /// - data: Combined nonce + ciphertext + tag. + /// - key: Symmetric key to use. + /// - Returns: Decrypted plaintext data. + /// - Throws: ``StorageError/securityApplicationFailed`` if decryption fails. private func decryptWithAESGCM(_ data: Data, using key: SymmetricKey) throws -> Data { do { let sealedBox = try AES.GCM.SealedBox(combined: data) @@ -247,6 +298,12 @@ actor EncryptionHelper { } /// Encrypts data using ChaChaPoly. + /// + /// - Parameters: + /// - data: Plaintext data to encrypt. + /// - key: Symmetric key to use. + /// - Returns: Combined nonce + ciphertext + tag. + /// - Throws: ``StorageError/securityApplicationFailed`` if encryption fails. private func encryptWithChaChaPoly(_ data: Data, using key: SymmetricKey) throws -> Data { do { let sealedBox = try ChaChaPoly.seal(data, using: key) @@ -257,6 +314,12 @@ actor EncryptionHelper { } /// Decrypts data using ChaChaPoly. + /// + /// - Parameters: + /// - data: Combined nonce + ciphertext + tag. + /// - key: Symmetric key to use. + /// - Returns: Decrypted plaintext data. + /// - Throws: ``StorageError/securityApplicationFailed`` if decryption fails. private func decryptWithChaChaPoly(_ data: Data, using key: SymmetricKey) throws -> Data { do { let sealedBox = try ChaChaPoly.SealedBox(combined: data) @@ -269,6 +332,14 @@ actor EncryptionHelper { // MARK: - PBKDF2 Implementation /// Derives key data using PBKDF2-SHA256. + /// + /// - Parameters: + /// - password: Base key material used as the PBKDF2 password. + /// - salt: Salt value to strengthen derivation. + /// - iterations: Number of PBKDF2 rounds. + /// - keyLength: Desired output length in bytes. + /// - Returns: Derived key material. + /// - Throws: ``StorageError/securityApplicationFailed`` if iterations are invalid. private func pbkdf2SHA256( password: Data, salt: Data, @@ -305,6 +376,11 @@ actor EncryptionHelper { } /// Computes HMAC-SHA256 for PBKDF2. + /// + /// - Parameters: + /// - key: HMAC key. + /// - data: Data to authenticate. + /// - Returns: HMAC output. private func hmacSHA256(key: Data, data: Data) -> Data { let symmetricKey = SymmetricKey(data: key) let mac = HMAC.authenticationCode(for: data, using: symmetricKey) @@ -312,18 +388,29 @@ actor EncryptionHelper { } /// XORs two data buffers for PBKDF2 chaining. + /// + /// - Parameters: + /// - left: First buffer. + /// - right: Second buffer. + /// - Returns: XORed data. 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. + /// + /// - Parameter value: Value to encode. + /// - Returns: Big-endian data representation. 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. + /// + /// - Parameter keyName: Key name used to build the salt. + /// - Returns: Salt data used for key derivation. private func defaultSalt(for keyName: String) -> Data { Data(keyName.utf8) } diff --git a/Sources/LocalData/Helpers/SyncHelper.swift b/Sources/LocalData/Helpers/SyncHelper.swift index c05e26a..c6a820c 100644 --- a/Sources/LocalData/Helpers/SyncHelper.swift +++ b/Sources/LocalData/Helpers/SyncHelper.swift @@ -3,16 +3,20 @@ import WatchConnectivity /// Actor that handles WatchConnectivity sync operations. /// -/// Manages data synchronization between iPhone and Apple Watch and provides -/// size- and policy-based gating for outbound sync. +/// `SyncHelper` manages data synchronization between iPhone and Apple Watch. +/// It enforces LocalData's sync policies, validates payload size constraints, +/// and delegates incoming application contexts to ``StorageRouter``. actor SyncHelper { /// Shared sync helper instance. /// - /// Prefer this shared instance in production code. Tests can inject a custom instance. + /// Prefer this shared instance in production code. Tests can inject a custom instance + /// with isolated configuration and `WCSession` behavior. public static let shared = SyncHelper() /// Current sync configuration. + /// + /// Controls size thresholds for automatic sync. private var configuration: SyncConfiguration /// Creates a helper with a specific configuration. @@ -39,12 +43,18 @@ actor SyncHelper { // MARK: - Public Interface /// Syncs data to the paired device if appropriate. + /// + /// This method enforces LocalData's eligibility rules: + /// - availability must be `.all` or `.phoneWithWatchSync` + /// - sync policy must be `.manual` or `.automaticSmall` + /// - `.automaticSmall` must be under the configured size threshold + /// /// - Parameters: /// - data: The data to sync. /// - keyName: The key name for the application context. /// - availability: The platform availability setting. /// - syncPolicy: The sync policy setting. - /// - Throws: `StorageError.dataTooLargeForSync` if data exceeds size limit for automatic sync. + /// - Throws: ``StorageError/dataTooLargeForSync`` if data exceeds size limit for automatic sync. public func syncIfNeeded( data: Data, keyName: String, @@ -72,6 +82,10 @@ actor SyncHelper { } /// Manually triggers a sync for the given data. + /// + /// This bypasses automatic size gating; callers should ensure payload size + /// is reasonable for application context updates. + /// /// - Parameters: /// - data: The data to sync. /// - keyName: The key name for the application context. @@ -138,6 +152,9 @@ actor SyncHelper { } /// Lazily configures and activates WCSession. + /// + /// LocalData does not own session lifecycle beyond the minimal setup needed + /// to send updates; app-level services should activate WCSession explicitly. private func setupSession() { let session = WCSession.default session.delegate = SessionDelegateProxy.shared @@ -165,11 +182,15 @@ actor SyncHelper { } } -/// Internal proxy class that routes WCSessionDelegate callbacks to ``SyncHelper``. +/// Internal proxy class that routes `WCSessionDelegate` callbacks to ``SyncHelper``. +/// +/// The proxy bridges delegate callbacks into async code without exposing +/// `WCSessionDelegate` conformance in the public API. internal final class SessionDelegateProxy: NSObject, WCSessionDelegate { /// Shared delegate proxy instance. static let shared = SessionDelegateProxy() + /// Handles WCSession activation completion. func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { if let error = error { Logger.error("WCSession activation failed: \(error.localizedDescription)") @@ -178,6 +199,7 @@ internal final class SessionDelegateProxy: NSObject, WCSessionDelegate { } } + /// Receives an updated application context and forwards it to `SyncHelper`. func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { Task { await SyncHelper.shared.handleReceivedContext(applicationContext) @@ -185,7 +207,9 @@ internal final class SessionDelegateProxy: NSObject, WCSessionDelegate { } #if os(iOS) + /// iOS-only callback when a session becomes inactive. func sessionDidBecomeInactive(_ session: WCSession) {} + /// iOS-only callback when a session deactivates. func sessionDidDeactivate(_ session: WCSession) { session.activate() } diff --git a/Sources/LocalData/Migrations/AppVersionConditionalMigration.swift b/Sources/LocalData/Migrations/AppVersionConditionalMigration.swift index e2de2ea..745256d 100644 --- a/Sources/LocalData/Migrations/AppVersionConditionalMigration.swift +++ b/Sources/LocalData/Migrations/AppVersionConditionalMigration.swift @@ -1,7 +1,10 @@ import Foundation /// Conditional migration that runs only when the app version is below a threshold. -public struct AppVersionConditionalMigration: ConditionalMigration { +/// +/// Use this wrapper to keep a legacy migration in place for older app versions +/// while allowing newer versions to skip it. +public struct AppVersionConditionalMigration: StorageMigration { /// Destination key for the migration. public let destinationKey: StorageKey /// Minimum app version required to skip this migration. @@ -10,6 +13,11 @@ public struct AppVersionConditionalMigration: Conditi public let fallbackMigration: AnyStorageMigration /// Creates a version-gated migration. + /// + /// - Parameters: + /// - destinationKey: The key that receives migrated data. + /// - minAppVersion: The minimum app version that should *skip* the migration. + /// - fallbackMigration: The migration to run when the version condition is met. public init( destinationKey: StorageKey, minAppVersion: String, diff --git a/Sources/LocalData/Migrations/DefaultAggregatingMigration.swift b/Sources/LocalData/Migrations/DefaultAggregatingMigration.swift index b8a393e..2a4c305 100644 --- a/Sources/LocalData/Migrations/DefaultAggregatingMigration.swift +++ b/Sources/LocalData/Migrations/DefaultAggregatingMigration.swift @@ -1,6 +1,10 @@ import Foundation /// Default migration that aggregates multiple source values into one destination value. +/// +/// Use this migration when you need to combine multiple legacy keys into a single +/// destination value (for example, building a new composite model from several +/// old preferences). public struct DefaultAggregatingMigration: AggregatingMigration { /// Destination key for aggregated data. public let destinationKey: StorageKey @@ -10,6 +14,11 @@ public struct DefaultAggregatingMigration: Aggregatin public let aggregateAction: @Sendable ([AnyCodable]) async throws -> Value /// Creates an aggregating migration with a custom aggregation closure. + /// + /// - Parameters: + /// - destinationKey: The key that receives the aggregated value. + /// - sourceKeys: The legacy keys to read and aggregate. + /// - aggregate: Closure that combines source values into a destination value. public init( destinationKey: StorageKey, sourceKeys: [AnyStorageKey], @@ -30,6 +39,11 @@ public struct DefaultAggregatingMigration: Aggregatin /// Determines whether the migration should run. /// + /// The migration runs when: + /// - the destination is allowed on the current platform + /// - the destination does not already exist + /// - at least one source key contains data + /// /// - Parameters: /// - router: The storage router used to query state. /// - context: Migration context for conditional checks. @@ -53,6 +67,12 @@ public struct DefaultAggregatingMigration: Aggregatin /// Executes the migration and returns a result. /// + /// The migration: + /// 1. Reads each source descriptor. + /// 2. Applies security to decode the raw data. + /// 3. Aggregates values into a destination value. + /// 4. Writes the destination value and deletes sources. + /// /// - Parameters: /// - router: The storage router used to read and write values. /// - context: Migration context for conditional checks. diff --git a/Sources/LocalData/Migrations/DefaultTransformingMigration.swift b/Sources/LocalData/Migrations/DefaultTransformingMigration.swift index eed3612..678d1a5 100644 --- a/Sources/LocalData/Migrations/DefaultTransformingMigration.swift +++ b/Sources/LocalData/Migrations/DefaultTransformingMigration.swift @@ -1,6 +1,9 @@ import Foundation /// Default migration that transforms a single source value into a destination value. +/// +/// Use this migration when the destination value type differs from the legacy +/// type or when you need to normalize/clean legacy data before storage. public struct DefaultTransformingMigration: TransformingMigration { /// Destination key for the transformed value. public let destinationKey: StorageKey @@ -10,6 +13,11 @@ public struct DefaultTransformingMigration DestinationValue /// Creates a transforming migration with a custom transform closure. + /// + /// - Parameters: + /// - destinationKey: The key that receives the transformed value. + /// - sourceKey: The legacy key providing the source value. + /// - transform: Closure that converts the source value into the destination type. public init( destinationKey: StorageKey, sourceKey: StorageKey, @@ -30,6 +38,11 @@ public struct DefaultTransformingMigration: StorageMigration { /// Destination key for migrated data. public let destinationKey: StorageKey @@ -8,6 +11,10 @@ public struct SimpleLegacyMigration: StorageMigration public let sourceKey: AnyStorageKey /// Creates a migration from a legacy key to a destination key. + /// + /// - Parameters: + /// - destinationKey: The key that receives migrated data. + /// - sourceKey: The legacy key to read and remove. public init(destinationKey: StorageKey, sourceKey: AnyStorageKey) { self.destinationKey = destinationKey self.sourceKey = sourceKey @@ -15,6 +22,11 @@ public struct SimpleLegacyMigration: StorageMigration /// Determines whether the migration should run. /// + /// The migration runs when: + /// - the destination is allowed on the current platform + /// - the destination does not already exist + /// - the source key contains data + /// /// - Parameters: /// - router: The storage router used to query state. /// - context: Migration context for conditional checks. @@ -32,6 +44,12 @@ public struct SimpleLegacyMigration: StorageMigration /// Executes the migration and returns a result. /// + /// The migration: + /// 1. Reads raw source data. + /// 2. Removes legacy security (if needed). + /// 3. Decodes into the destination value type. + /// 4. Writes to the destination and deletes the source. + /// /// - Parameters: /// - router: The storage router used to read and write values. /// - context: Migration context for conditional checks. diff --git a/Sources/LocalData/Models/AnyStorageKey.swift b/Sources/LocalData/Models/AnyStorageKey.swift index def4262..13a6a42 100644 --- a/Sources/LocalData/Models/AnyStorageKey.swift +++ b/Sources/LocalData/Models/AnyStorageKey.swift @@ -1,9 +1,15 @@ /// Type-erased wrapper around ``StorageKey`` for catalogs and audits. +/// +/// `StorageKey` is generic over its `Value` type, so heterogeneous keys cannot +/// be stored in a single array without type erasure. `AnyStorageKey` captures +/// the descriptor and optional migration, enabling catalog registration and +/// audit reporting. public struct AnyStorageKey: Sendable { /// Snapshot of key metadata for auditing and storage operations. public internal(set) var descriptor: StorageKeyDescriptor /// Optional migration associated with the key. public internal(set) var migration: AnyStorageMigration? + /// Migration executor captured from the original key. private let migrateAction: @Sendable (StorageRouter) async throws -> Void /// Creates a type-erased key from a typed ``StorageKey``. @@ -17,6 +23,12 @@ public struct AnyStorageKey: Sendable { } } + /// Internal initializer for constructing modified wrappers. + /// + /// - Parameters: + /// - descriptor: Descriptor describing the key. + /// - migration: Optional migration. + /// - migrateAction: Closure to execute migration for the key. private init( descriptor: StorageKeyDescriptor, migration: AnyStorageMigration?, @@ -30,6 +42,7 @@ public struct AnyStorageKey: Sendable { /// Convenience factory for creating a type-erased key. /// /// - Parameter key: The concrete key to erase. + /// - Returns: A type-erased key wrapper. public static func key(_ key: StorageKey) -> AnyStorageKey { AnyStorageKey(key) } diff --git a/Sources/LocalData/Models/AnyStorageMigration.swift b/Sources/LocalData/Models/AnyStorageMigration.swift index f7415b6..2d979bb 100644 --- a/Sources/LocalData/Models/AnyStorageMigration.swift +++ b/Sources/LocalData/Models/AnyStorageMigration.swift @@ -1,11 +1,20 @@ import Foundation /// Type-erased wrapper for ``StorageMigration`` for use in catalogs and registrations. +/// +/// `StorageMigration` is a protocol with an associated type, which makes it +/// difficult to store heterogeneous migrations in a single collection. This +/// wrapper captures the migration's behavior and destination descriptor while +/// preserving sendability. public struct AnyStorageMigration: Sendable { /// Descriptor for the migration destination key. + /// + /// Useful for auditing and for determining the target of the migration. public let destinationDescriptor: StorageKeyDescriptor + /// Captured closure that decides whether migration should run. private let shouldMigrateAction: @Sendable (StorageRouter, MigrationContext) async throws -> Bool + /// Captured closure that executes the migration. private let migrateAction: @Sendable (StorageRouter, MigrationContext) async throws -> MigrationResult /// Creates a type-erased migration from a concrete migration. diff --git a/Sources/LocalData/Models/FileDirectory.swift b/Sources/LocalData/Models/FileDirectory.swift index 01a089a..908f019 100644 --- a/Sources/LocalData/Models/FileDirectory.swift +++ b/Sources/LocalData/Models/FileDirectory.swift @@ -1,15 +1,26 @@ import Foundation /// File system directory for file-based storage. +/// +/// Use this enum to describe where a file should live within the app sandbox +/// or a custom file URL. public enum FileDirectory: Sendable, Hashable { /// App documents directory. + /// + /// Best for user-facing or critical data that should be backed up. case documents /// App caches directory. + /// + /// Best for temporary or re-creatable data that may be purged by the system. case caches /// Custom directory URL. + /// + /// Use this to scope storage to a custom location (including App Group paths). case custom(URL) /// Resolves the directory to a concrete URL. + /// + /// - Returns: The resolved directory URL. public func url() -> URL { switch self { case .documents: diff --git a/Sources/LocalData/Models/KeyMaterialSource.swift b/Sources/LocalData/Models/KeyMaterialSource.swift index bf822cd..a093f61 100644 --- a/Sources/LocalData/Models/KeyMaterialSource.swift +++ b/Sources/LocalData/Models/KeyMaterialSource.swift @@ -1,11 +1,18 @@ import Foundation /// Identifier for external key material providers. +/// +/// Use this type to register and reference external key material when using +/// ``SecurityPolicy/EncryptionPolicy/external(source:keyDerivation:)``. public struct KeyMaterialSource: Hashable, Sendable { /// Stable identifier for the provider or key source. + /// + /// This should be deterministic and consistent across app launches. public let id: String /// Creates a new key material source identifier. + /// + /// - Parameter id: Stable identifier for the external key provider. public init(id: String) { self.id = id } diff --git a/Sources/LocalData/Models/MigrationContext.swift b/Sources/LocalData/Models/MigrationContext.swift index 8e87ede..3580026 100644 --- a/Sources/LocalData/Models/MigrationContext.swift +++ b/Sources/LocalData/Models/MigrationContext.swift @@ -1,6 +1,9 @@ import Foundation /// Context information available for conditional migrations. +/// +/// Migrations use this context to decide whether they should run and to tailor +/// behavior based on app version, device, system state, and historical data. public struct MigrationContext: Sendable { /// Current app version string. public let appVersion: String @@ -14,6 +17,13 @@ public struct MigrationContext: Sendable { public let systemInfo: SystemInfo /// Creates a migration context with optional overrides. + /// + /// - Parameters: + /// - appVersion: Current app version string. Defaults to the bundle version. + /// - deviceInfo: Device metadata for platform checks. + /// - migrationHistory: Historical migration timestamps by key name. + /// - userPreferences: Optional preferences influencing migration behavior. + /// - systemInfo: System information snapshot. public init( appVersion: String = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown", deviceInfo: DeviceInfo = .current, diff --git a/Sources/LocalData/Models/MigrationError.swift b/Sources/LocalData/Models/MigrationError.swift index af0bbdd..b5c5566 100644 --- a/Sources/LocalData/Models/MigrationError.swift +++ b/Sources/LocalData/Models/MigrationError.swift @@ -1,6 +1,9 @@ import Foundation /// Migration-specific error types. +/// +/// These errors describe why a migration failed or could not run, and are used +/// in ``MigrationResult`` for reporting. public enum MigrationError: Error, Sendable, Equatable { /// Validation failed before migration could run. case validationFailed(String) diff --git a/Sources/LocalData/Models/MigrationResult.swift b/Sources/LocalData/Models/MigrationResult.swift index 3308aef..54060ca 100644 --- a/Sources/LocalData/Models/MigrationResult.swift +++ b/Sources/LocalData/Models/MigrationResult.swift @@ -1,19 +1,42 @@ import Foundation -/// Result of a migration operation with detailed information. +/// Records the outcome of a migration along with counts, errors, and metadata. +/// +/// `MigrationResult` is the canonical payload returned by migrations so callers can +/// surface success/failure, auditing details, and performance data in a consistent way. +/// It is intentionally lightweight and `Sendable` so it can cross concurrency boundaries. public struct MigrationResult: Sendable { - /// Whether the migration completed successfully. + /// Indicates whether the migration completed without fatal errors. + /// + /// A value of `false` does not always mean that no data moved; consult + /// `migratedCount` and `errors` for partial outcomes. public let success: Bool - /// Number of values migrated. + /// Number of items or records migrated during the operation. + /// + /// This count is used for audit logging and metrics. Its meaning depends on the + /// migration type (for example, key-by-key versus batch). public let migratedCount: Int - /// Errors captured during migration. + /// Errors captured while the migration executed. + /// + /// Consumers should surface these to developers and use them to decide on retries. public let errors: [MigrationError] - /// Additional metadata provided by the migration. + /// Additional metadata emitted by the migration for diagnostics or reporting. + /// + /// The dictionary must only contain `AnyCodable` values so results remain portable. public let metadata: [String: AnyCodable] /// Duration of the migration in seconds. + /// + /// Use this value for instrumentation and to flag unusually slow migrations. public let duration: TimeInterval /// Creates a migration result with optional details. + /// + /// - Parameters: + /// - success: Whether the migration completed without fatal errors. + /// - migratedCount: Number of items migrated. Defaults to `0`. + /// - errors: Errors captured during the migration. Defaults to an empty array. + /// - metadata: Metadata emitted by the migration. Defaults to an empty dictionary. + /// - duration: Duration in seconds. Defaults to `0`. public init( success: Bool, migratedCount: Int = 0, diff --git a/Sources/LocalData/Models/PlatformAvailability.swift b/Sources/LocalData/Models/PlatformAvailability.swift index a3e10f3..646cd20 100644 --- a/Sources/LocalData/Models/PlatformAvailability.swift +++ b/Sources/LocalData/Models/PlatformAvailability.swift @@ -1,20 +1,35 @@ import Foundation -/// Specifies which platforms a storage key is allowed to run on. +/// Declares which platforms a storage key can be used on. +/// +/// `PlatformAvailability` guides the router when enforcing platform-specific usage. +/// For example, watch-only keys prevent accidental reads on iOS, while sync-enabled +/// keys signal that watch-to-phone data flows should be configured. public enum PlatformAvailability: Sendable { /// Available on iOS and watchOS (small data only on watch). + /// + /// Use this for data that is safe to exist on both devices without explicit sync. case all /// Available only on iOS (large or sensitive data). + /// + /// Prefer this for data that is too large or too sensitive for watch storage. case phoneOnly /// Available only on watchOS. + /// + /// Use this for watch-local data that should not be mirrored to iPhone. case watchOnly /// Available on iOS and watchOS with explicit sync behavior. + /// + /// Use this when your key participates in a defined sync strategy. case phoneWithWatchSync } /// Convenience helpers for platform checks. public extension PlatformAvailability { - /// Returns `true` if the key should be available on the given platform. + /// Returns `true` when the key is permitted on the supplied platform. + /// + /// - Parameter platform: The runtime platform to evaluate. + /// - Returns: `true` if the key can be used on the platform, otherwise `false`. func isAvailable(on platform: Platform) -> Bool { switch self { case .all: diff --git a/Sources/LocalData/Models/SecurityPolicy.swift b/Sources/LocalData/Models/SecurityPolicy.swift index f2db56b..6f6561a 100644 --- a/Sources/LocalData/Models/SecurityPolicy.swift +++ b/Sources/LocalData/Models/SecurityPolicy.swift @@ -2,40 +2,71 @@ import Foundation import CryptoKit import Security -/// Security policy for a ``StorageKey``. +/// Describes how a ``StorageKey`` secures its persisted data. +/// +/// A `SecurityPolicy` is attached to each key so the storage layer can enforce +/// encryption or Keychain placement consistently across the app. public enum SecurityPolicy: Equatable, Sendable { /// Stores data without additional security. + /// + /// Use this only for non-sensitive, non-personal data. case none /// Encrypts data before storage using the specified policy. + /// + /// The encryption policy describes the algorithm and key derivation strategy. case encrypted(EncryptionPolicy) /// Stores data directly in the Keychain with accessibility and access control options. + /// + /// Use this for credentials or secrets that must remain in Keychain storage. case keychain(accessibility: KeychainAccessibility, accessControl: KeychainAccessControl?) /// Recommended security policy for most sensitive data. + /// + /// This defaults to the recommended encryption policy to keep data encrypted at rest. public static let recommended: SecurityPolicy = .encrypted(.recommended) /// Encryption algorithm and key derivation settings. + /// + /// Use these options to align with organizational security requirements. public enum EncryptionPolicy: Equatable, Sendable { /// AES-256-GCM encryption. + /// + /// Choose when AES is preferred for compliance or interoperability reasons. case aes256(keyDerivation: KeyDerivation) /// ChaCha20-Poly1305 encryption. + /// + /// This is the recommended default for modern Apple platforms. case chacha20Poly1305(keyDerivation: KeyDerivation) /// External key material with key derivation. + /// + /// Use this when key material is provided by an HSM, secure enclave, or other source. case external(source: KeyMaterialSource, keyDerivation: KeyDerivation) /// Recommended encryption policy for most cases. + /// + /// Uses ChaCha20-Poly1305 with HKDF-derived keys. public static let recommended: EncryptionPolicy = .chacha20Poly1305(keyDerivation: .hkdf()) /// Convenience for external key material with default HKDF. + /// + /// - Parameter source: The external key material provider. + /// - Returns: A policy configured with the default HKDF parameters. public static func external(source: KeyMaterialSource) -> EncryptionPolicy { .external(source: source, keyDerivation: .hkdf()) } } /// Key derivation algorithms for encryption keys. + /// + /// These settings allow you to tune how raw key material is transformed into + /// encryption keys used by the selected algorithm. public enum KeyDerivation: Equatable, Sendable { /// PBKDF2 with optional iterations and salt. + /// + /// Provide `iterations` and `salt` when you need deterministic derivation. case pbkdf2(iterations: Int? = nil, salt: Data? = nil) /// HKDF with optional salt and info. + /// + /// Supply `info` to domain-separate keys for distinct purposes. case hkdf(salt: Data? = nil, info: Data? = nil) } } diff --git a/Sources/LocalData/Models/Serializer.swift b/Sources/LocalData/Models/Serializer.swift index 330dd6b..5561002 100644 --- a/Sources/LocalData/Models/Serializer.swift +++ b/Sources/LocalData/Models/Serializer.swift @@ -1,12 +1,21 @@ import Foundation /// Encodes and decodes values for storage. +/// +/// `Serializer` packages paired encode/decode closures with a human-readable name +/// so storage services can serialize values consistently and report the format used. public struct Serializer: Sendable, CustomStringConvertible { /// Encodes a value into `Data`. + /// + /// The closure must be `Sendable` so it can execute safely across concurrency contexts. public let encode: @Sendable (Value) throws -> Data /// Decodes a value from `Data`. + /// + /// The closure must be `Sendable` so it can execute safely across concurrency contexts. public let decode: @Sendable (Data) throws -> Value /// Human-readable serializer name used in audit reports. + /// + /// Keep names stable so audit output is predictable and searchable. public let name: String /// Creates a custom serializer. @@ -26,6 +35,8 @@ public struct Serializer: Sendable, CustomStringConve } /// Description used by `CustomStringConvertible`. + /// + /// Mirrors the `name` so logs and debug output show the configured serializer. public var description: String { name } /// JSON serializer using `JSONEncoder` and `JSONDecoder`. @@ -70,6 +81,8 @@ public struct Serializer: Sendable, CustomStringConve public extension Serializer where Value == Data { /// Serializer that passes through raw `Data`. /// + /// Use this when the caller already owns the encoding format. + /// /// - Returns: A serializer that returns `Data` unchanged. static var data: Serializer { Serializer(encode: { $0 }, decode: { $0 }, name: "data") diff --git a/Sources/LocalData/Models/StorageDomain.swift b/Sources/LocalData/Models/StorageDomain.swift index 8eb011b..d0f11fb 100644 --- a/Sources/LocalData/Models/StorageDomain.swift +++ b/Sources/LocalData/Models/StorageDomain.swift @@ -1,17 +1,32 @@ import Foundation /// Storage location for a ``StorageKey``. +/// +/// `StorageDomain` describes where values are stored and which storage backend is used. +/// The router interprets these cases to route reads and writes to the correct helper. public enum StorageDomain: Sendable, Equatable { /// Standard `UserDefaults` using the provided suite name. + /// + /// Pass `nil` to target the default `UserDefaults` suite. case userDefaults(suite: String?) /// App group `UserDefaults` using the provided group identifier. + /// + /// Use this for data shared with extensions on the same device. case appGroupUserDefaults(identifier: String?) /// Keychain storage using the provided service identifier. + /// + /// Keychain storage is used for sensitive or credential-like data. case keychain(service: String?) /// File system storage in the specified directory. + /// + /// Suitable for larger values that should not live in defaults or Keychain. case fileSystem(directory: FileDirectory) /// Encrypted file system storage in the specified directory. + /// + /// Values are encrypted before being written to the file system. case encryptedFileSystem(directory: FileDirectory) /// App group file storage using the group identifier and directory. + /// + /// Use this for file-backed data that needs to be shared with app extensions. case appGroupFileSystem(identifier: String?, directory: FileDirectory) } diff --git a/Sources/LocalData/Models/StorageError.swift b/Sources/LocalData/Models/StorageError.swift index ffbd2dd..f5aed4d 100644 --- a/Sources/LocalData/Models/StorageError.swift +++ b/Sources/LocalData/Models/StorageError.swift @@ -1,37 +1,68 @@ import Foundation /// Errors thrown by storage operations and migrations. +/// +/// `StorageError` standardizes failures across storage helpers so callers can handle +/// issues consistently, whether the underlying storage is defaults, files, or Keychain. public enum StorageError: Error, Equatable { /// Failed to encode a value. + /// + /// This indicates the serializer could not transform a value into `Data`. case serializationFailed /// Failed to decode stored data. + /// + /// This typically means the stored payload does not match the expected type. case deserializationFailed /// Failed to apply or remove security for stored data. + /// + /// This error is used when encryption or decryption fails. case securityApplicationFailed /// Underlying Keychain error. + /// + /// The associated `OSStatus` comes from Security framework APIs. case keychainError(OSStatus) /// File system error description. + /// + /// Uses a `String` to preserve a descriptive message while remaining `Equatable`. case fileError(String) // Changed from Error to String for easier Equatable conformance /// A phone-only key was accessed on watchOS. + /// + /// The associated value is the key name. case phoneOnlyKeyAccessedOnWatch(String) /// A watch-only key was accessed on iOS. + /// + /// The associated value is the key name. case watchOnlyKeyAccessedOnPhone(String) /// Invalid UserDefaults suite name. + /// + /// The associated value is the invalid suite identifier. case invalidUserDefaultsSuite(String) /// Invalid App Group identifier. + /// + /// The associated value is the invalid group identifier. case invalidAppGroupIdentifier(String) /// Sync payload exceeded the configured maximum size. + /// + /// The key cannot be synced because the payload is too large. case dataTooLargeForSync /// No value exists for the requested key. case notFound /// The key is not registered in any catalog. + /// + /// The associated value is the missing key name. case unregisteredKey(String) /// Duplicate key names detected during registration. + /// + /// The associated array lists the duplicate key names. case duplicateRegisteredKeys([String]) /// Missing or empty key description. + /// + /// The associated value is the key name missing the description. case missingDescription(String) /// Compares two storage errors for equality. + /// + /// This custom equality handles associated values, including `OSStatus`. public static func == (lhs: StorageError, rhs: StorageError) -> Bool { switch (lhs, rhs) { case (.serializationFailed, .serializationFailed), @@ -59,4 +90,8 @@ public enum StorageError: Error, Equatable { } } +/// `StorageError` includes `OSStatus` values which are not `Sendable` by default. +/// +/// The enum is effectively immutable, so we mark it `@unchecked Sendable` to allow +/// it to cross concurrency boundaries. extension StorageError: @unchecked Sendable {} diff --git a/Sources/LocalData/Models/StorageKeyDescriptor.swift b/Sources/LocalData/Models/StorageKeyDescriptor.swift index f5af324..2db56ea 100644 --- a/Sources/LocalData/Models/StorageKeyDescriptor.swift +++ b/Sources/LocalData/Models/StorageKeyDescriptor.swift @@ -1,29 +1,54 @@ import Foundation /// Snapshot of a ``StorageKey`` used for audit and registration. +/// +/// `StorageKeyDescriptor` captures the immutable metadata needed for audits, duplicate +/// detection, and catalog reporting without carrying the key's generic type. public struct StorageKeyDescriptor: Sendable, CustomStringConvertible { /// Key name within its domain. + /// + /// This is the primary identifier used for duplicate detection. public let name: String /// Storage domain for the key. + /// + /// The router uses this to determine which storage helper should be used. public let domain: StorageDomain /// Security policy applied to the key. + /// + /// Indicates whether encryption or Keychain placement is required. public let security: SecurityPolicy /// Serializer name used for encoding/decoding. + /// + /// This is recorded for audit output; the serializer itself remains on the key. public let serializer: String /// String representation of the value type. + /// + /// Useful for diagnostics when reviewing audit reports. public let valueType: String /// Owning module or feature name. + /// + /// Use this to identify the feature responsible for the stored data. public let owner: String /// Platform availability for the key. + /// + /// Governs which runtime platforms are allowed to access the key. public let availability: PlatformAvailability /// Sync policy for WatchConnectivity. + /// + /// Used when determining sync eligibility and constraints. public let syncPolicy: SyncPolicy /// Human-readable description for audit reports. + /// + /// Descriptions are required to keep audit results interpretable. public let description: String /// Optional catalog name the key belongs to. + /// + /// Catalog names aid in grouping keys by feature or module. public let catalog: String? /// Internal initializer used by factories and audits. + /// + /// Callers should prefer `from(_:)` unless building descriptors manually. init( name: String, domain: StorageDomain, diff --git a/Sources/LocalData/Models/SyncPolicy.swift b/Sources/LocalData/Models/SyncPolicy.swift index 2430c69..9d22926 100644 --- a/Sources/LocalData/Models/SyncPolicy.swift +++ b/Sources/LocalData/Models/SyncPolicy.swift @@ -1,11 +1,20 @@ import Foundation /// Defines how a key participates in WatchConnectivity sync. +/// +/// `SyncPolicy` is interpreted by sync helpers to decide if and when data should +/// be pushed between iPhone and Apple Watch. public enum SyncPolicy: Sendable { /// No sync behavior. + /// + /// Use this for keys that should remain device-local. case never /// Sync only when the app explicitly requests it. + /// + /// Use this when you need full control over sync timing. case manual /// Automatically sync when data size is below the configured threshold. + /// + /// Use this for small payloads that should stay in sync without manual triggers. case automaticSmall } diff --git a/Sources/LocalData/Protocols/AggregatingMigration.swift b/Sources/LocalData/Protocols/AggregatingMigration.swift index 676577d..81a8d5d 100644 --- a/Sources/LocalData/Protocols/AggregatingMigration.swift +++ b/Sources/LocalData/Protocols/AggregatingMigration.swift @@ -1,3 +1,6 @@ +/// Defines migrations that aggregate multiple source keys into a single destination. +/// +/// Use this protocol when a new storage key is derived from multiple legacy values. import Foundation /// Migration protocol that combines multiple sources into a single destination. diff --git a/Sources/LocalData/Protocols/ConditionalMigration.swift b/Sources/LocalData/Protocols/ConditionalMigration.swift deleted file mode 100644 index f0a35bc..0000000 --- a/Sources/LocalData/Protocols/ConditionalMigration.swift +++ /dev/null @@ -1,4 +0,0 @@ -import Foundation - -/// Marker protocol for migrations that primarily use conditional checks. -public protocol ConditionalMigration: StorageMigration {} diff --git a/Sources/LocalData/Protocols/KeyMaterialProviding.swift b/Sources/LocalData/Protocols/KeyMaterialProviding.swift index c328f50..ab34a7d 100644 --- a/Sources/LocalData/Protocols/KeyMaterialProviding.swift +++ b/Sources/LocalData/Protocols/KeyMaterialProviding.swift @@ -1,3 +1,6 @@ +/// Supplies encryption key material to support external key sources. +/// +/// Conformers provide raw bytes used by ``SecurityPolicy.EncryptionPolicy.external``. import Foundation /// Supplies external key material for encryption policies. diff --git a/Sources/LocalData/Protocols/KeychainStoring.swift b/Sources/LocalData/Protocols/KeychainStoring.swift index 2c10f37..f8c5f14 100644 --- a/Sources/LocalData/Protocols/KeychainStoring.swift +++ b/Sources/LocalData/Protocols/KeychainStoring.swift @@ -1,3 +1,6 @@ +/// Defines the Keychain operations required by the storage layer. +/// +/// This protocol enables swapping concrete Keychain implementations for testing. import Foundation /// Protocol defining the interface for Keychain operations. diff --git a/Sources/LocalData/Protocols/StorageKeyCatalog.swift b/Sources/LocalData/Protocols/StorageKeyCatalog.swift index 3fa5d91..3e02d1d 100644 --- a/Sources/LocalData/Protocols/StorageKeyCatalog.swift +++ b/Sources/LocalData/Protocols/StorageKeyCatalog.swift @@ -1,3 +1,6 @@ +// Defines catalog types that group storage keys for registration and auditing. +// +// Catalogs are the mechanism used by the router to validate keys and detect duplicates. /// Collection of storage keys used for registration and auditing. public protocol StorageKeyCatalog: Sendable { /// Human-readable catalog name used in audit reports. diff --git a/Sources/LocalData/Protocols/StorageMigration.swift b/Sources/LocalData/Protocols/StorageMigration.swift index 96a4d3d..511193d 100644 --- a/Sources/LocalData/Protocols/StorageMigration.swift +++ b/Sources/LocalData/Protocols/StorageMigration.swift @@ -1,3 +1,6 @@ +/// Defines the core migration contract used by the storage router. +/// +/// All migration types build on this protocol to move or transform stored data. import Foundation /// Core migration protocol for moving data into a destination ``StorageKey``. diff --git a/Sources/LocalData/Protocols/StorageProviding.swift b/Sources/LocalData/Protocols/StorageProviding.swift index ade3dac..33afb8d 100644 --- a/Sources/LocalData/Protocols/StorageProviding.swift +++ b/Sources/LocalData/Protocols/StorageProviding.swift @@ -1,3 +1,6 @@ +/// Defines the storage operations required by concrete backends. +/// +/// Storage helpers conform to this protocol to provide a common API surface. import Foundation /// Abstraction for basic storage operations. diff --git a/Sources/LocalData/Protocols/TransformingMigration.swift b/Sources/LocalData/Protocols/TransformingMigration.swift index 92a4cdd..bd3db5b 100644 --- a/Sources/LocalData/Protocols/TransformingMigration.swift +++ b/Sources/LocalData/Protocols/TransformingMigration.swift @@ -1,3 +1,6 @@ +/// Defines migrations that transform a source value into a new destination value. +/// +/// Use this protocol when a single legacy key can be mapped to a new schema. import Foundation /// Migration protocol that transforms a source value into a destination value.