Compare commits
No commits in common. "d95e5660ef181d1ebfea80078c0205d847a1d66a" and "2a42e3dba0e2d1e668145e4a900df5f88fc10827" have entirely different histories.
d95e5660ef
...
2a42e3dba0
@ -1,9 +1,16 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Renders audit reports for storage key catalogs and registries.
|
/// 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 {
|
public struct StorageAuditReport: Sendable {
|
||||||
/// Returns descriptors for all keys in a catalog.
|
/// 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.
|
/// - Parameter catalog: Catalog containing keys to describe.
|
||||||
/// - Returns: An array of ``StorageKeyDescriptor`` values.
|
/// - Returns: An array of ``StorageKeyDescriptor`` values.
|
||||||
public static func items(for catalog: some StorageKeyCatalog) -> [StorageKeyDescriptor] {
|
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.
|
/// 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.
|
/// - Parameter entries: The keys to render.
|
||||||
/// - Returns: A newline-delimited report string.
|
/// - Returns: A newline-delimited report string.
|
||||||
public static func renderText(_ entries: [AnyStorageKey]) -> 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.
|
/// 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.
|
/// - Returns: A report string grouped by catalog name.
|
||||||
public static func renderGlobalRegistryGrouped() async -> String {
|
public static func renderGlobalRegistryGrouped() async -> String {
|
||||||
let catalogs = await StorageRouter.shared.allRegisteredCatalogs()
|
let catalogs = await StorageRouter.shared.allRegisteredCatalogs()
|
||||||
@ -77,6 +90,10 @@ public struct StorageAuditReport: Sendable {
|
|||||||
return lines.joined(separator: "\n")
|
return lines.joined(separator: "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
private static func string(for domain: StorageDomain) -> String {
|
||||||
switch domain {
|
switch domain {
|
||||||
case .userDefaults(let suite):
|
case .userDefaults(let suite):
|
||||||
@ -94,6 +111,10 @@ 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 {
|
private static func string(for directory: FileDirectory) -> String {
|
||||||
switch directory {
|
switch directory {
|
||||||
case .documents:
|
case .documents:
|
||||||
@ -105,6 +126,10 @@ 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 {
|
private static func string(for availability: PlatformAvailability) -> String {
|
||||||
switch availability {
|
switch availability {
|
||||||
case .all:
|
case .all:
|
||||||
@ -118,6 +143,10 @@ 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 {
|
private static func string(for syncPolicy: SyncPolicy) -> String {
|
||||||
switch syncPolicy {
|
switch syncPolicy {
|
||||||
case .never:
|
case .never:
|
||||||
@ -129,6 +158,10 @@ 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 {
|
private static func string(for security: SecurityPolicy) -> String {
|
||||||
switch security {
|
switch security {
|
||||||
case .none:
|
case .none:
|
||||||
@ -141,6 +174,10 @@ 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 {
|
private static func string(for policy: SecurityPolicy.EncryptionPolicy) -> String {
|
||||||
switch policy {
|
switch policy {
|
||||||
case .aes256(let derivation):
|
case .aes256(let derivation):
|
||||||
@ -152,6 +189,10 @@ 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 {
|
private static func string(for derivation: SecurityPolicy.KeyDerivation) -> String {
|
||||||
switch derivation {
|
switch derivation {
|
||||||
case .pbkdf2(let iterations, _):
|
case .pbkdf2(let iterations, _):
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Configuration for the encryption system.
|
/// 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 {
|
public struct EncryptionConfiguration: Sendable {
|
||||||
/// Keychain service for the master key.
|
/// Keychain service for the master key.
|
||||||
public let masterKeyService: String
|
public let masterKeyService: String
|
||||||
@ -14,6 +18,16 @@ public struct EncryptionConfiguration: Sendable {
|
|||||||
public let pbkdf2Iterations: Int
|
public let pbkdf2Iterations: Int
|
||||||
|
|
||||||
/// Creates an encryption configuration.
|
/// 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(
|
public init(
|
||||||
masterKeyService: String = "LocalData",
|
masterKeyService: String = "LocalData",
|
||||||
masterKeyAccount: String = "MasterKey",
|
masterKeyAccount: String = "MasterKey",
|
||||||
|
|||||||
@ -1,16 +1,26 @@
|
|||||||
import Foundation
|
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 {
|
public struct FileStorageConfiguration: Sendable {
|
||||||
/// An optional sub-directory to scope all library files within.
|
/// 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?
|
public let subDirectory: String?
|
||||||
|
|
||||||
/// An optional base URL to override the default system directories.
|
/// An optional base URL to override the default system directories.
|
||||||
|
///
|
||||||
/// Primarily used for testing isolation.
|
/// Primarily used for testing isolation.
|
||||||
public let baseURL: URL?
|
public let baseURL: URL?
|
||||||
|
|
||||||
/// Creates a file storage configuration.
|
/// 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) {
|
public init(subDirectory: String? = nil, baseURL: URL? = nil) {
|
||||||
self.subDirectory = subDirectory
|
self.subDirectory = subDirectory
|
||||||
self.baseURL = baseURL
|
self.baseURL = baseURL
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Global configuration for the storage engine.
|
/// 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 {
|
public struct StorageConfiguration: Sendable {
|
||||||
/// The default Keychain service to use if none is specified in a StorageKey.
|
/// The default Keychain service to use if none is specified in a StorageKey.
|
||||||
public let defaultKeychainService: String?
|
public let defaultKeychainService: String?
|
||||||
@ -10,6 +12,10 @@ public struct StorageConfiguration: Sendable {
|
|||||||
public let defaultAppGroupIdentifier: String?
|
public let defaultAppGroupIdentifier: String?
|
||||||
|
|
||||||
/// Creates a configuration with optional defaults.
|
/// Creates a configuration with optional defaults.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - defaultKeychainService: Default Keychain service identifier.
|
||||||
|
/// - defaultAppGroupIdentifier: Default App Group identifier.
|
||||||
public init(
|
public init(
|
||||||
defaultKeychainService: String? = nil,
|
defaultKeychainService: String? = nil,
|
||||||
defaultAppGroupIdentifier: String? = nil
|
defaultAppGroupIdentifier: String? = nil
|
||||||
|
|||||||
@ -1,11 +1,15 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Configuration for WatchConnectivity sync operations.
|
/// Configuration for WatchConnectivity sync operations.
|
||||||
|
///
|
||||||
|
/// Controls payload limits for automatic sync.
|
||||||
public struct SyncConfiguration: Sendable {
|
public struct SyncConfiguration: Sendable {
|
||||||
/// Maximum data size for automatic sync in bytes.
|
/// Maximum data size for automatic sync in bytes.
|
||||||
public let maxAutoSyncSize: Int
|
public let maxAutoSyncSize: Int
|
||||||
|
|
||||||
/// Creates a sync configuration.
|
/// Creates a sync configuration.
|
||||||
|
///
|
||||||
|
/// - Parameter maxAutoSyncSize: Maximum payload size in bytes.
|
||||||
public init(maxAutoSyncSize: Int = 100_000) {
|
public init(maxAutoSyncSize: Int = 100_000) {
|
||||||
self.maxAutoSyncSize = maxAutoSyncSize
|
self.maxAutoSyncSize = maxAutoSyncSize
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,14 +3,29 @@ import CryptoKit
|
|||||||
|
|
||||||
/// Actor that handles all encryption and decryption operations.
|
/// Actor that handles all encryption and decryption operations.
|
||||||
///
|
///
|
||||||
/// Uses AES-GCM or ChaChaPoly for symmetric encryption with derived keys.
|
/// `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 {
|
actor EncryptionHelper {
|
||||||
|
|
||||||
/// Shared encryption helper instance.
|
/// Shared encryption helper 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()
|
public static let shared = EncryptionHelper()
|
||||||
|
|
||||||
|
/// Current encryption configuration.
|
||||||
|
///
|
||||||
|
/// Controls key derivation defaults and master key settings.
|
||||||
private var configuration: EncryptionConfiguration
|
private var configuration: EncryptionConfiguration
|
||||||
|
/// Keychain provider used for master key storage.
|
||||||
|
///
|
||||||
|
/// Abstracted to allow test stubs and isolation.
|
||||||
private var keychain: KeychainStoring
|
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] = [:]
|
private var keyMaterialProviders: [KeyMaterialSource: any KeyMaterialProviding] = [:]
|
||||||
|
|
||||||
/// Creates an encryption helper with a configuration and keychain provider.
|
/// Creates an encryption helper with a configuration and keychain provider.
|
||||||
@ -31,6 +46,8 @@ actor EncryptionHelper {
|
|||||||
/// Updates the configuration for the actor.
|
/// Updates the configuration for the actor.
|
||||||
///
|
///
|
||||||
/// - Parameter configuration: New encryption configuration.
|
/// - 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) {
|
public func updateConfiguration(_ configuration: EncryptionConfiguration) {
|
||||||
self.configuration = configuration
|
self.configuration = configuration
|
||||||
}
|
}
|
||||||
@ -38,7 +55,7 @@ actor EncryptionHelper {
|
|||||||
/// Updates the keychain helper used for master key storage.
|
/// Updates the keychain helper used for master key storage.
|
||||||
///
|
///
|
||||||
/// - Parameter keychain: Keychain provider to use.
|
/// - Parameter keychain: Keychain provider to use.
|
||||||
/// - Note: Internal for testing isolation.
|
/// - Note: Intended for testing isolation and dependency injection.
|
||||||
public func updateKeychainHelper(_ keychain: KeychainStoring) {
|
public func updateKeychainHelper(_ keychain: KeychainStoring) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
}
|
}
|
||||||
@ -50,6 +67,7 @@ actor EncryptionHelper {
|
|||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - provider: The provider that supplies key material.
|
/// - provider: The provider that supplies key material.
|
||||||
/// - source: Identifier used to look up the provider.
|
/// - source: Identifier used to look up the provider.
|
||||||
|
/// - Note: Required when using ``SecurityPolicy/EncryptionPolicy/external(source:keyDerivation:)``.
|
||||||
public func registerKeyMaterialProvider(
|
public func registerKeyMaterialProvider(
|
||||||
_ provider: any KeyMaterialProviding,
|
_ provider: any KeyMaterialProviding,
|
||||||
for source: KeyMaterialSource
|
for source: KeyMaterialSource
|
||||||
@ -58,12 +76,13 @@ actor EncryptionHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Encrypts data using AES-GCM or ChaChaPoly.
|
/// Encrypts data using AES-GCM or ChaChaPoly.
|
||||||
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - data: The plaintext data to encrypt.
|
/// - data: The plaintext data to encrypt.
|
||||||
/// - keyName: A unique name used for key derivation salt.
|
/// - keyName: A unique name used for key derivation salt.
|
||||||
/// - policy: The encryption policy specifying algorithm and key derivation.
|
/// - policy: The encryption policy specifying algorithm and key derivation.
|
||||||
/// - Returns: The encrypted data (nonce + ciphertext + tag combined).
|
/// - Returns: The encrypted data (nonce + ciphertext + tag combined).
|
||||||
/// - Throws: `StorageError.securityApplicationFailed` if encryption fails.
|
/// - Throws: ``StorageError/securityApplicationFailed`` if encryption fails.
|
||||||
public func encrypt(
|
public func encrypt(
|
||||||
_ data: Data,
|
_ data: Data,
|
||||||
keyName: String,
|
keyName: String,
|
||||||
@ -74,12 +93,13 @@ actor EncryptionHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypts data using AES-GCM or ChaChaPoly.
|
/// Decrypts data using AES-GCM or ChaChaPoly.
|
||||||
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - data: The encrypted data (nonce + ciphertext + tag combined).
|
/// - data: The encrypted data (nonce + ciphertext + tag combined).
|
||||||
/// - keyName: The same unique name used during encryption.
|
/// - keyName: The same unique name used during encryption.
|
||||||
/// - policy: The same encryption policy used during encryption.
|
/// - policy: The same encryption policy used during encryption.
|
||||||
/// - Returns: The decrypted plaintext data.
|
/// - Returns: The decrypted plaintext data.
|
||||||
/// - Throws: `StorageError.securityApplicationFailed` if decryption fails.
|
/// - Throws: ``StorageError/securityApplicationFailed`` if decryption fails.
|
||||||
public func decrypt(
|
public func decrypt(
|
||||||
_ data: Data,
|
_ data: Data,
|
||||||
keyName: String,
|
keyName: String,
|
||||||
@ -91,7 +111,13 @@ actor EncryptionHelper {
|
|||||||
|
|
||||||
// MARK: - Key Derivation
|
// 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(
|
private func deriveKey(
|
||||||
keyName: String,
|
keyName: String,
|
||||||
policy: SecurityPolicy.EncryptionPolicy
|
policy: SecurityPolicy.EncryptionPolicy
|
||||||
@ -118,7 +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(
|
private func deriveKeyMaterial(
|
||||||
keyName: String,
|
keyName: String,
|
||||||
derivation: SecurityPolicy.KeyDerivation,
|
derivation: SecurityPolicy.KeyDerivation,
|
||||||
@ -148,7 +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 {
|
private func getMasterKey() async throws -> Data {
|
||||||
if let existing = try await keychain.get(
|
if let existing = try await keychain.get(
|
||||||
service: configuration.masterKeyService,
|
service: configuration.masterKeyService,
|
||||||
@ -180,6 +216,14 @@ actor EncryptionHelper {
|
|||||||
|
|
||||||
// MARK: - AES-GCM Operations
|
// 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(
|
private func encryptWithKey(
|
||||||
_ data: Data,
|
_ data: Data,
|
||||||
using key: SymmetricKey,
|
using key: SymmetricKey,
|
||||||
@ -195,6 +239,13 @@ 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 {
|
private func encryptWithAESGCM(_ data: Data, using key: SymmetricKey) throws -> Data {
|
||||||
do {
|
do {
|
||||||
let sealedBox = try AES.GCM.seal(data, using: key)
|
let sealedBox = try AES.GCM.seal(data, using: key)
|
||||||
@ -207,6 +258,14 @@ 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(
|
private func decryptWithKey(
|
||||||
_ data: Data,
|
_ data: Data,
|
||||||
using key: SymmetricKey,
|
using key: SymmetricKey,
|
||||||
@ -222,6 +281,13 @@ 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 {
|
private func decryptWithAESGCM(_ data: Data, using key: SymmetricKey) throws -> Data {
|
||||||
do {
|
do {
|
||||||
let sealedBox = try AES.GCM.SealedBox(combined: data)
|
let sealedBox = try AES.GCM.SealedBox(combined: data)
|
||||||
@ -231,6 +297,13 @@ 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 {
|
private func encryptWithChaChaPoly(_ data: Data, using key: SymmetricKey) throws -> Data {
|
||||||
do {
|
do {
|
||||||
let sealedBox = try ChaChaPoly.seal(data, using: key)
|
let sealedBox = try ChaChaPoly.seal(data, using: key)
|
||||||
@ -240,6 +313,13 @@ 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 {
|
private func decryptWithChaChaPoly(_ data: Data, using key: SymmetricKey) throws -> Data {
|
||||||
do {
|
do {
|
||||||
let sealedBox = try ChaChaPoly.SealedBox(combined: data)
|
let sealedBox = try ChaChaPoly.SealedBox(combined: data)
|
||||||
@ -251,6 +331,15 @@ actor EncryptionHelper {
|
|||||||
|
|
||||||
// MARK: - PBKDF2 Implementation
|
// 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(
|
private func pbkdf2SHA256(
|
||||||
password: Data,
|
password: Data,
|
||||||
salt: Data,
|
salt: Data,
|
||||||
@ -286,22 +375,42 @@ actor EncryptionHelper {
|
|||||||
return derivedKey.prefix(keyLength)
|
return derivedKey.prefix(keyLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Computes HMAC-SHA256 for PBKDF2.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - key: HMAC key.
|
||||||
|
/// - data: Data to authenticate.
|
||||||
|
/// - Returns: HMAC output.
|
||||||
private func hmacSHA256(key: Data, data: Data) -> Data {
|
private func hmacSHA256(key: Data, data: Data) -> Data {
|
||||||
let symmetricKey = SymmetricKey(data: key)
|
let symmetricKey = SymmetricKey(data: key)
|
||||||
let mac = HMAC<SHA256>.authenticationCode(for: data, using: symmetricKey)
|
let mac = HMAC<SHA256>.authenticationCode(for: data, using: symmetricKey)
|
||||||
return Data(mac)
|
return Data(mac)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
private func xor(_ left: Data, _ right: Data) -> Data {
|
||||||
let xored = zip(left, right).map { $0 ^ $1 }
|
let xored = zip(left, right).map { $0 ^ $1 }
|
||||||
return Data(xored)
|
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 {
|
private func uint32BigEndian(_ value: UInt32) -> Data {
|
||||||
var bigEndian = value.bigEndian
|
var bigEndian = value.bigEndian
|
||||||
return Data(bytes: &bigEndian, count: MemoryLayout<UInt32>.size)
|
return Data(bytes: &bigEndian, count: MemoryLayout<UInt32>.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 {
|
private func defaultSalt(for keyName: String) -> Data {
|
||||||
Data(keyName.utf8)
|
Data(keyName.utf8)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,12 +3,15 @@ import Foundation
|
|||||||
/// Actor that handles all file system operations.
|
/// Actor that handles all file system operations.
|
||||||
///
|
///
|
||||||
/// Provides thread-safe file reading, writing, deletion, and listing for
|
/// 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 {
|
actor FileStorageHelper {
|
||||||
|
|
||||||
/// Shared file storage helper instance.
|
/// Shared file storage helper instance.
|
||||||
|
///
|
||||||
|
/// Prefer this shared instance in production code. Tests can inject a custom instance.
|
||||||
public static let shared = FileStorageHelper()
|
public static let shared = FileStorageHelper()
|
||||||
|
|
||||||
|
/// Current file storage configuration.
|
||||||
private var configuration: FileStorageConfiguration
|
private var configuration: FileStorageConfiguration
|
||||||
|
|
||||||
/// Creates a helper with a specific configuration.
|
/// Creates a helper with a specific configuration.
|
||||||
@ -241,6 +244,10 @@ actor FileStorageHelper {
|
|||||||
|
|
||||||
// MARK: - Private Helpers
|
// 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 {
|
private func ensureDirectoryExists(at url: URL) throws {
|
||||||
guard !FileManager.default.fileExists(atPath: url.path) else {
|
guard !FileManager.default.fileExists(atPath: url.path) else {
|
||||||
return
|
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 {
|
private func write(_ data: Data, to url: URL, useCompleteFileProtection: Bool) throws {
|
||||||
var options: Data.WritingOptions = [.atomic]
|
var options: Data.WritingOptions = [.atomic]
|
||||||
if useCompleteFileProtection {
|
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? {
|
private func read(from url: URL) throws -> Data? {
|
||||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||||
return nil
|
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 {
|
private func delete(file url: URL) throws {
|
||||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||||
return
|
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] {
|
private func list(in url: URL) throws -> [String] {
|
||||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||||
return []
|
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? {
|
private func size(of url: URL) throws -> Int64? {
|
||||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||||
return nil
|
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 {
|
private func appGroupContainerURL(identifier: String) throws -> URL {
|
||||||
guard let url = FileManager.default.containerURL(
|
guard let url = FileManager.default.containerURL(
|
||||||
forSecurityApplicationGroupIdentifier: identifier
|
forSecurityApplicationGroupIdentifier: identifier
|
||||||
@ -334,6 +372,12 @@ actor FileStorageHelper {
|
|||||||
return url
|
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 {
|
private func resolveDirectoryURL(baseURL overrideURL: URL? = nil, directory: FileDirectory) throws -> URL {
|
||||||
let base: URL
|
let base: URL
|
||||||
// Priority: 1. Method override, 2. Configuration override, 3. System default
|
// Priority: 1. Method override, 2. Configuration override, 3. System default
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import Security
|
|||||||
actor KeychainHelper: KeychainStoring {
|
actor KeychainHelper: KeychainStoring {
|
||||||
|
|
||||||
/// Shared keychain helper instance.
|
/// Shared keychain helper instance.
|
||||||
|
///
|
||||||
|
/// Prefer this shared instance in production code. Tests can inject a custom instance.
|
||||||
public static let shared = KeychainHelper()
|
public static let shared = KeychainHelper()
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
@ -168,6 +170,12 @@ actor KeychainHelper: KeychainStoring {
|
|||||||
|
|
||||||
// MARK: - Private Helpers
|
// 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] {
|
private func baseQuery(service: String, key: String) -> [String: Any] {
|
||||||
return [
|
return [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
|||||||
@ -3,12 +3,20 @@ import WatchConnectivity
|
|||||||
|
|
||||||
/// Actor that handles WatchConnectivity sync operations.
|
/// Actor that handles WatchConnectivity sync operations.
|
||||||
///
|
///
|
||||||
/// Manages data synchronization between iPhone and Apple Watch.
|
/// `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 {
|
actor SyncHelper {
|
||||||
|
|
||||||
/// Shared sync helper instance.
|
/// Shared sync helper 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()
|
public static let shared = SyncHelper()
|
||||||
|
|
||||||
|
/// Current sync configuration.
|
||||||
|
///
|
||||||
|
/// Controls size thresholds for automatic sync.
|
||||||
private var configuration: SyncConfiguration
|
private var configuration: SyncConfiguration
|
||||||
|
|
||||||
/// Creates a helper with a specific configuration.
|
/// Creates a helper with a specific configuration.
|
||||||
@ -35,12 +43,18 @@ actor SyncHelper {
|
|||||||
// MARK: - Public Interface
|
// MARK: - Public Interface
|
||||||
|
|
||||||
/// Syncs data to the paired device if appropriate.
|
/// 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:
|
/// - Parameters:
|
||||||
/// - data: The data to sync.
|
/// - data: The data to sync.
|
||||||
/// - keyName: The key name for the application context.
|
/// - keyName: The key name for the application context.
|
||||||
/// - availability: The platform availability setting.
|
/// - availability: The platform availability setting.
|
||||||
/// - syncPolicy: The sync policy 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(
|
public func syncIfNeeded(
|
||||||
data: Data,
|
data: Data,
|
||||||
keyName: String,
|
keyName: String,
|
||||||
@ -68,6 +82,10 @@ actor SyncHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Manually triggers a sync for the given data.
|
/// 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:
|
/// - Parameters:
|
||||||
/// - data: The data to sync.
|
/// - data: The data to sync.
|
||||||
/// - keyName: The key name for the application context.
|
/// - keyName: The key name for the application context.
|
||||||
@ -107,6 +125,12 @@ actor SyncHelper {
|
|||||||
|
|
||||||
// MARK: - Private Helpers
|
// 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 {
|
private func performSync(data: Data, keyName: String) throws {
|
||||||
guard WCSession.isSupported() else { return }
|
guard WCSession.isSupported() else { return }
|
||||||
|
|
||||||
@ -127,6 +151,10 @@ actor SyncHelper {
|
|||||||
Logger.info("<<< [SYNC] Application context updated for key: \(keyName). Keys now: [\(contextKeys)]")
|
Logger.info("<<< [SYNC] Application context updated for key: \(keyName). Keys now: [\(contextKeys)]")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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() {
|
private func setupSession() {
|
||||||
let session = WCSession.default
|
let session = WCSession.default
|
||||||
session.delegate = SessionDelegateProxy.shared
|
session.delegate = SessionDelegateProxy.shared
|
||||||
@ -134,7 +162,9 @@ actor SyncHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Handles received application context from the paired device.
|
/// 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 {
|
internal func handleReceivedContext(_ context: [String: Any]) async {
|
||||||
Logger.info(">>> [SYNC] Received application context with \(context.count) keys")
|
Logger.info(">>> [SYNC] Received application context with \(context.count) keys")
|
||||||
for (key, value) in context {
|
for (key, value) in context {
|
||||||
@ -152,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 {
|
internal final class SessionDelegateProxy: NSObject, WCSessionDelegate {
|
||||||
/// Shared delegate proxy instance.
|
/// Shared delegate proxy instance.
|
||||||
static let shared = SessionDelegateProxy()
|
static let shared = SessionDelegateProxy()
|
||||||
|
|
||||||
|
/// Handles WCSession activation completion.
|
||||||
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
||||||
if let error = error {
|
if let error = error {
|
||||||
Logger.error("WCSession activation failed: \(error.localizedDescription)")
|
Logger.error("WCSession activation failed: \(error.localizedDescription)")
|
||||||
@ -165,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]) {
|
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
||||||
Task {
|
Task {
|
||||||
await SyncHelper.shared.handleReceivedContext(applicationContext)
|
await SyncHelper.shared.handleReceivedContext(applicationContext)
|
||||||
@ -172,7 +207,9 @@ internal final class SessionDelegateProxy: NSObject, WCSessionDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
|
/// iOS-only callback when a session becomes inactive.
|
||||||
func sessionDidBecomeInactive(_ session: WCSession) {}
|
func sessionDidBecomeInactive(_ session: WCSession) {}
|
||||||
|
/// iOS-only callback when a session deactivates.
|
||||||
func sessionDidDeactivate(_ session: WCSession) {
|
func sessionDidDeactivate(_ session: WCSession) {
|
||||||
session.activate()
|
session.activate()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,8 +6,11 @@ import Foundation
|
|||||||
actor UserDefaultsHelper {
|
actor UserDefaultsHelper {
|
||||||
|
|
||||||
/// Shared helper instance.
|
/// Shared helper instance.
|
||||||
|
///
|
||||||
|
/// Prefer this shared instance in production code. Tests can inject a custom instance.
|
||||||
public static let shared = UserDefaultsHelper()
|
public static let shared = UserDefaultsHelper()
|
||||||
|
|
||||||
|
/// Underlying defaults instance used when no suite is specified.
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
/// Creates a helper with a specific `UserDefaults` instance.
|
/// Creates a helper with a specific `UserDefaults` instance.
|
||||||
@ -125,6 +128,11 @@ actor UserDefaultsHelper {
|
|||||||
|
|
||||||
// MARK: - Private Helpers
|
// 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 {
|
private func userDefaults(for suite: String?) throws -> UserDefaults {
|
||||||
if let suite {
|
if let suite {
|
||||||
guard let suiteDefaults = UserDefaults(suiteName: suite) else {
|
guard let suiteDefaults = UserDefaults(suiteName: suite) else {
|
||||||
@ -135,6 +143,11 @@ actor UserDefaultsHelper {
|
|||||||
return defaults
|
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 {
|
private func appGroupDefaults(for identifier: String) throws -> UserDefaults {
|
||||||
guard let defaults = UserDefaults(suiteName: identifier) else {
|
guard let defaults = UserDefaults(suiteName: identifier) else {
|
||||||
throw StorageError.invalidAppGroupIdentifier(identifier)
|
throw StorageError.invalidAppGroupIdentifier(identifier)
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Conditional migration that runs only when the app version is below a threshold.
|
/// Conditional migration that runs only when the app version is below a threshold.
|
||||||
public struct AppVersionConditionalMigration<Value: Codable & Sendable>: 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<Value: Codable & Sendable>: StorageMigration {
|
||||||
/// Destination key for the migration.
|
/// Destination key for the migration.
|
||||||
public let destinationKey: StorageKey<Value>
|
public let destinationKey: StorageKey<Value>
|
||||||
/// Minimum app version required to skip this migration.
|
/// Minimum app version required to skip this migration.
|
||||||
@ -10,6 +13,11 @@ public struct AppVersionConditionalMigration<Value: Codable & Sendable>: Conditi
|
|||||||
public let fallbackMigration: AnyStorageMigration
|
public let fallbackMigration: AnyStorageMigration
|
||||||
|
|
||||||
/// Creates a version-gated migration.
|
/// 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(
|
public init(
|
||||||
destinationKey: StorageKey<Value>,
|
destinationKey: StorageKey<Value>,
|
||||||
minAppVersion: String,
|
minAppVersion: String,
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Default migration that aggregates multiple source values into one destination value.
|
/// 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<Value: Codable & Sendable>: AggregatingMigration {
|
public struct DefaultAggregatingMigration<Value: Codable & Sendable>: AggregatingMigration {
|
||||||
/// Destination key for aggregated data.
|
/// Destination key for aggregated data.
|
||||||
public let destinationKey: StorageKey<Value>
|
public let destinationKey: StorageKey<Value>
|
||||||
@ -10,6 +14,11 @@ public struct DefaultAggregatingMigration<Value: Codable & Sendable>: Aggregatin
|
|||||||
public let aggregateAction: @Sendable ([AnyCodable]) async throws -> Value
|
public let aggregateAction: @Sendable ([AnyCodable]) async throws -> Value
|
||||||
|
|
||||||
/// Creates an aggregating migration with a custom aggregation closure.
|
/// 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(
|
public init(
|
||||||
destinationKey: StorageKey<Value>,
|
destinationKey: StorageKey<Value>,
|
||||||
sourceKeys: [AnyStorageKey],
|
sourceKeys: [AnyStorageKey],
|
||||||
@ -30,6 +39,11 @@ public struct DefaultAggregatingMigration<Value: Codable & Sendable>: Aggregatin
|
|||||||
|
|
||||||
/// Determines whether the migration should run.
|
/// 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:
|
/// - Parameters:
|
||||||
/// - router: The storage router used to query state.
|
/// - router: The storage router used to query state.
|
||||||
/// - context: Migration context for conditional checks.
|
/// - context: Migration context for conditional checks.
|
||||||
@ -53,6 +67,12 @@ public struct DefaultAggregatingMigration<Value: Codable & Sendable>: Aggregatin
|
|||||||
|
|
||||||
/// Executes the migration and returns a result.
|
/// 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:
|
/// - Parameters:
|
||||||
/// - router: The storage router used to read and write values.
|
/// - router: The storage router used to read and write values.
|
||||||
/// - context: Migration context for conditional checks.
|
/// - context: Migration context for conditional checks.
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Default migration that transforms a single source value into a destination value.
|
/// 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<SourceValue: Codable & Sendable, DestinationValue: Codable & Sendable>: TransformingMigration {
|
public struct DefaultTransformingMigration<SourceValue: Codable & Sendable, DestinationValue: Codable & Sendable>: TransformingMigration {
|
||||||
/// Destination key for the transformed value.
|
/// Destination key for the transformed value.
|
||||||
public let destinationKey: StorageKey<DestinationValue>
|
public let destinationKey: StorageKey<DestinationValue>
|
||||||
@ -10,6 +13,11 @@ public struct DefaultTransformingMigration<SourceValue: Codable & Sendable, Dest
|
|||||||
public let transformAction: @Sendable (SourceValue) async throws -> DestinationValue
|
public let transformAction: @Sendable (SourceValue) async throws -> DestinationValue
|
||||||
|
|
||||||
/// Creates a transforming migration with a custom transform closure.
|
/// 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(
|
public init(
|
||||||
destinationKey: StorageKey<DestinationValue>,
|
destinationKey: StorageKey<DestinationValue>,
|
||||||
sourceKey: StorageKey<SourceValue>,
|
sourceKey: StorageKey<SourceValue>,
|
||||||
@ -30,6 +38,11 @@ public struct DefaultTransformingMigration<SourceValue: Codable & Sendable, Dest
|
|||||||
|
|
||||||
/// Determines whether the migration should run.
|
/// 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:
|
/// - Parameters:
|
||||||
/// - router: The storage router used to query state.
|
/// - router: The storage router used to query state.
|
||||||
/// - context: Migration context for conditional checks.
|
/// - context: Migration context for conditional checks.
|
||||||
@ -47,6 +60,12 @@ public struct DefaultTransformingMigration<SourceValue: Codable & Sendable, Dest
|
|||||||
|
|
||||||
/// Executes the migration and returns a result.
|
/// Executes the migration and returns a result.
|
||||||
///
|
///
|
||||||
|
/// The migration:
|
||||||
|
/// 1. Reads the source value.
|
||||||
|
/// 2. Transforms it into the destination type.
|
||||||
|
/// 3. Writes the destination value.
|
||||||
|
/// 4. Deletes the source key.
|
||||||
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - router: The storage router used to read and write values.
|
/// - router: The storage router used to read and write values.
|
||||||
/// - context: Migration context for conditional checks.
|
/// - context: Migration context for conditional checks.
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Simple 1:1 legacy migration from a single source key.
|
/// Simple 1:1 legacy migration from a single source key.
|
||||||
|
///
|
||||||
|
/// Use this migration when the source and destination value types are identical
|
||||||
|
/// and no transformation is required.
|
||||||
public struct SimpleLegacyMigration<Value: Codable & Sendable>: StorageMigration {
|
public struct SimpleLegacyMigration<Value: Codable & Sendable>: StorageMigration {
|
||||||
/// Destination key for migrated data.
|
/// Destination key for migrated data.
|
||||||
public let destinationKey: StorageKey<Value>
|
public let destinationKey: StorageKey<Value>
|
||||||
@ -8,6 +11,10 @@ public struct SimpleLegacyMigration<Value: Codable & Sendable>: StorageMigration
|
|||||||
public let sourceKey: AnyStorageKey
|
public let sourceKey: AnyStorageKey
|
||||||
|
|
||||||
/// Creates a migration from a legacy key to a destination key.
|
/// 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<Value>, sourceKey: AnyStorageKey) {
|
public init(destinationKey: StorageKey<Value>, sourceKey: AnyStorageKey) {
|
||||||
self.destinationKey = destinationKey
|
self.destinationKey = destinationKey
|
||||||
self.sourceKey = sourceKey
|
self.sourceKey = sourceKey
|
||||||
@ -15,6 +22,11 @@ public struct SimpleLegacyMigration<Value: Codable & Sendable>: StorageMigration
|
|||||||
|
|
||||||
/// Determines whether the migration should run.
|
/// 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:
|
/// - Parameters:
|
||||||
/// - router: The storage router used to query state.
|
/// - router: The storage router used to query state.
|
||||||
/// - context: Migration context for conditional checks.
|
/// - context: Migration context for conditional checks.
|
||||||
@ -32,6 +44,12 @@ public struct SimpleLegacyMigration<Value: Codable & Sendable>: StorageMigration
|
|||||||
|
|
||||||
/// Executes the migration and returns a result.
|
/// 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:
|
/// - Parameters:
|
||||||
/// - router: The storage router used to read and write values.
|
/// - router: The storage router used to read and write values.
|
||||||
/// - context: Migration context for conditional checks.
|
/// - context: Migration context for conditional checks.
|
||||||
|
|||||||
@ -1,9 +1,15 @@
|
|||||||
/// Type-erased wrapper around ``StorageKey`` for catalogs and audits.
|
/// 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 {
|
public struct AnyStorageKey: Sendable {
|
||||||
/// Snapshot of key metadata for auditing and storage operations.
|
/// Snapshot of key metadata for auditing and storage operations.
|
||||||
public internal(set) var descriptor: StorageKeyDescriptor
|
public internal(set) var descriptor: StorageKeyDescriptor
|
||||||
/// Optional migration associated with the key.
|
/// Optional migration associated with the key.
|
||||||
public internal(set) var migration: AnyStorageMigration?
|
public internal(set) var migration: AnyStorageMigration?
|
||||||
|
/// Migration executor captured from the original key.
|
||||||
private let migrateAction: @Sendable (StorageRouter) async throws -> Void
|
private let migrateAction: @Sendable (StorageRouter) async throws -> Void
|
||||||
|
|
||||||
/// Creates a type-erased key from a typed ``StorageKey``.
|
/// 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(
|
private init(
|
||||||
descriptor: StorageKeyDescriptor,
|
descriptor: StorageKeyDescriptor,
|
||||||
migration: AnyStorageMigration?,
|
migration: AnyStorageMigration?,
|
||||||
@ -30,11 +42,15 @@ public struct AnyStorageKey: Sendable {
|
|||||||
/// Convenience factory for creating a type-erased key.
|
/// Convenience factory for creating a type-erased key.
|
||||||
///
|
///
|
||||||
/// - Parameter key: The concrete key to erase.
|
/// - Parameter key: The concrete key to erase.
|
||||||
|
/// - Returns: A type-erased key wrapper.
|
||||||
public static func key<Value>(_ key: StorageKey<Value>) -> AnyStorageKey {
|
public static func key<Value>(_ key: StorageKey<Value>) -> AnyStorageKey {
|
||||||
AnyStorageKey(key)
|
AnyStorageKey(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Internal use: Returns a copy of this key with the catalog name set.
|
/// 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 {
|
internal func withCatalog(_ name: String) -> AnyStorageKey {
|
||||||
AnyStorageKey(
|
AnyStorageKey(
|
||||||
descriptor: descriptor.withCatalog(name),
|
descriptor: descriptor.withCatalog(name),
|
||||||
@ -44,6 +60,9 @@ public struct AnyStorageKey: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Internal use: Triggers the migration logic for this key.
|
/// 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 {
|
internal func migrate(on router: StorageRouter) async throws {
|
||||||
try await migrateAction(router)
|
try await migrateAction(router)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,20 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Type-erased wrapper for ``StorageMigration`` for use in catalogs and registrations.
|
/// 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 {
|
public struct AnyStorageMigration: Sendable {
|
||||||
/// Descriptor for the migration destination key.
|
/// Descriptor for the migration destination key.
|
||||||
|
///
|
||||||
|
/// Useful for auditing and for determining the target of the migration.
|
||||||
public let destinationDescriptor: StorageKeyDescriptor
|
public let destinationDescriptor: StorageKeyDescriptor
|
||||||
|
|
||||||
|
/// Captured closure that decides whether migration should run.
|
||||||
private let shouldMigrateAction: @Sendable (StorageRouter, MigrationContext) async throws -> Bool
|
private let shouldMigrateAction: @Sendable (StorageRouter, MigrationContext) async throws -> Bool
|
||||||
|
/// Captured closure that executes the migration.
|
||||||
private let migrateAction: @Sendable (StorageRouter, MigrationContext) async throws -> MigrationResult
|
private let migrateAction: @Sendable (StorageRouter, MigrationContext) async throws -> MigrationResult
|
||||||
|
|
||||||
/// Creates a type-erased migration from a concrete migration.
|
/// Creates a type-erased migration from a concrete migration.
|
||||||
|
|||||||
@ -1,15 +1,26 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// File system directory for file-based storage.
|
/// 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 {
|
public enum FileDirectory: Sendable, Hashable {
|
||||||
/// App documents directory.
|
/// App documents directory.
|
||||||
|
///
|
||||||
|
/// Best for user-facing or critical data that should be backed up.
|
||||||
case documents
|
case documents
|
||||||
/// App caches directory.
|
/// App caches directory.
|
||||||
|
///
|
||||||
|
/// Best for temporary or re-creatable data that may be purged by the system.
|
||||||
case caches
|
case caches
|
||||||
/// Custom directory URL.
|
/// Custom directory URL.
|
||||||
|
///
|
||||||
|
/// Use this to scope storage to a custom location (including App Group paths).
|
||||||
case custom(URL)
|
case custom(URL)
|
||||||
|
|
||||||
/// Resolves the directory to a concrete URL.
|
/// Resolves the directory to a concrete URL.
|
||||||
|
///
|
||||||
|
/// - Returns: The resolved directory URL.
|
||||||
public func url() -> URL {
|
public func url() -> URL {
|
||||||
switch self {
|
switch self {
|
||||||
case .documents:
|
case .documents:
|
||||||
|
|||||||
@ -1,11 +1,18 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Identifier for external key material providers.
|
/// 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 {
|
public struct KeyMaterialSource: Hashable, Sendable {
|
||||||
/// Stable identifier for the provider or key source.
|
/// Stable identifier for the provider or key source.
|
||||||
|
///
|
||||||
|
/// This should be deterministic and consistent across app launches.
|
||||||
public let id: String
|
public let id: String
|
||||||
|
|
||||||
/// Creates a new key material source identifier.
|
/// Creates a new key material source identifier.
|
||||||
|
///
|
||||||
|
/// - Parameter id: Stable identifier for the external key provider.
|
||||||
public init(id: String) {
|
public init(id: String) {
|
||||||
self.id = id
|
self.id = id
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Context information available for conditional migrations.
|
/// 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 {
|
public struct MigrationContext: Sendable {
|
||||||
/// Current app version string.
|
/// Current app version string.
|
||||||
public let appVersion: String
|
public let appVersion: String
|
||||||
@ -14,6 +17,13 @@ public struct MigrationContext: Sendable {
|
|||||||
public let systemInfo: SystemInfo
|
public let systemInfo: SystemInfo
|
||||||
|
|
||||||
/// Creates a migration context with optional overrides.
|
/// 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(
|
public init(
|
||||||
appVersion: String = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown",
|
appVersion: String = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown",
|
||||||
deviceInfo: DeviceInfo = .current,
|
deviceInfo: DeviceInfo = .current,
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Migration-specific error types.
|
/// 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 {
|
public enum MigrationError: Error, Sendable, Equatable {
|
||||||
/// Validation failed before migration could run.
|
/// Validation failed before migration could run.
|
||||||
case validationFailed(String)
|
case validationFailed(String)
|
||||||
|
|||||||
@ -1,19 +1,42 @@
|
|||||||
import Foundation
|
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 {
|
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
|
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
|
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]
|
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]
|
public let metadata: [String: AnyCodable]
|
||||||
/// Duration of the migration in seconds.
|
/// Duration of the migration in seconds.
|
||||||
|
///
|
||||||
|
/// Use this value for instrumentation and to flag unusually slow migrations.
|
||||||
public let duration: TimeInterval
|
public let duration: TimeInterval
|
||||||
|
|
||||||
/// Creates a migration result with optional details.
|
/// 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(
|
public init(
|
||||||
success: Bool,
|
success: Bool,
|
||||||
migratedCount: Int = 0,
|
migratedCount: Int = 0,
|
||||||
|
|||||||
@ -1,20 +1,35 @@
|
|||||||
import Foundation
|
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 {
|
public enum PlatformAvailability: Sendable {
|
||||||
/// Available on iOS and watchOS (small data only on watch).
|
/// 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
|
case all
|
||||||
/// Available only on iOS (large or sensitive data).
|
/// Available only on iOS (large or sensitive data).
|
||||||
|
///
|
||||||
|
/// Prefer this for data that is too large or too sensitive for watch storage.
|
||||||
case phoneOnly
|
case phoneOnly
|
||||||
/// Available only on watchOS.
|
/// Available only on watchOS.
|
||||||
|
///
|
||||||
|
/// Use this for watch-local data that should not be mirrored to iPhone.
|
||||||
case watchOnly
|
case watchOnly
|
||||||
/// Available on iOS and watchOS with explicit sync behavior.
|
/// Available on iOS and watchOS with explicit sync behavior.
|
||||||
|
///
|
||||||
|
/// Use this when your key participates in a defined sync strategy.
|
||||||
case phoneWithWatchSync
|
case phoneWithWatchSync
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience helpers for platform checks.
|
/// Convenience helpers for platform checks.
|
||||||
public extension PlatformAvailability {
|
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 {
|
func isAvailable(on platform: Platform) -> Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .all:
|
case .all:
|
||||||
|
|||||||
@ -2,40 +2,71 @@ import Foundation
|
|||||||
import CryptoKit
|
import CryptoKit
|
||||||
import Security
|
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 {
|
public enum SecurityPolicy: Equatable, Sendable {
|
||||||
/// Stores data without additional security.
|
/// Stores data without additional security.
|
||||||
|
///
|
||||||
|
/// Use this only for non-sensitive, non-personal data.
|
||||||
case none
|
case none
|
||||||
/// Encrypts data before storage using the specified policy.
|
/// Encrypts data before storage using the specified policy.
|
||||||
|
///
|
||||||
|
/// The encryption policy describes the algorithm and key derivation strategy.
|
||||||
case encrypted(EncryptionPolicy)
|
case encrypted(EncryptionPolicy)
|
||||||
/// Stores data directly in the Keychain with accessibility and access control options.
|
/// 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?)
|
case keychain(accessibility: KeychainAccessibility, accessControl: KeychainAccessControl?)
|
||||||
|
|
||||||
/// Recommended security policy for most sensitive data.
|
/// 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)
|
public static let recommended: SecurityPolicy = .encrypted(.recommended)
|
||||||
|
|
||||||
/// Encryption algorithm and key derivation settings.
|
/// Encryption algorithm and key derivation settings.
|
||||||
|
///
|
||||||
|
/// Use these options to align with organizational security requirements.
|
||||||
public enum EncryptionPolicy: Equatable, Sendable {
|
public enum EncryptionPolicy: Equatable, Sendable {
|
||||||
/// AES-256-GCM encryption.
|
/// AES-256-GCM encryption.
|
||||||
|
///
|
||||||
|
/// Choose when AES is preferred for compliance or interoperability reasons.
|
||||||
case aes256(keyDerivation: KeyDerivation)
|
case aes256(keyDerivation: KeyDerivation)
|
||||||
/// ChaCha20-Poly1305 encryption.
|
/// ChaCha20-Poly1305 encryption.
|
||||||
|
///
|
||||||
|
/// This is the recommended default for modern Apple platforms.
|
||||||
case chacha20Poly1305(keyDerivation: KeyDerivation)
|
case chacha20Poly1305(keyDerivation: KeyDerivation)
|
||||||
/// External key material with key derivation.
|
/// 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)
|
case external(source: KeyMaterialSource, keyDerivation: KeyDerivation)
|
||||||
|
|
||||||
/// Recommended encryption policy for most cases.
|
/// Recommended encryption policy for most cases.
|
||||||
|
///
|
||||||
|
/// Uses ChaCha20-Poly1305 with HKDF-derived keys.
|
||||||
public static let recommended: EncryptionPolicy = .chacha20Poly1305(keyDerivation: .hkdf())
|
public static let recommended: EncryptionPolicy = .chacha20Poly1305(keyDerivation: .hkdf())
|
||||||
/// Convenience for external key material with default 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 {
|
public static func external(source: KeyMaterialSource) -> EncryptionPolicy {
|
||||||
.external(source: source, keyDerivation: .hkdf())
|
.external(source: source, keyDerivation: .hkdf())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Key derivation algorithms for encryption keys.
|
/// 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 {
|
public enum KeyDerivation: Equatable, Sendable {
|
||||||
/// PBKDF2 with optional iterations and salt.
|
/// PBKDF2 with optional iterations and salt.
|
||||||
|
///
|
||||||
|
/// Provide `iterations` and `salt` when you need deterministic derivation.
|
||||||
case pbkdf2(iterations: Int? = nil, salt: Data? = nil)
|
case pbkdf2(iterations: Int? = nil, salt: Data? = nil)
|
||||||
/// HKDF with optional salt and info.
|
/// HKDF with optional salt and info.
|
||||||
|
///
|
||||||
|
/// Supply `info` to domain-separate keys for distinct purposes.
|
||||||
case hkdf(salt: Data? = nil, info: Data? = nil)
|
case hkdf(salt: Data? = nil, info: Data? = nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,21 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Encodes and decodes values for storage.
|
/// 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<Value: Codable & Sendable>: Sendable, CustomStringConvertible {
|
public struct Serializer<Value: Codable & Sendable>: Sendable, CustomStringConvertible {
|
||||||
/// Encodes a value into `Data`.
|
/// 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
|
public let encode: @Sendable (Value) throws -> Data
|
||||||
/// Decodes a value from `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
|
public let decode: @Sendable (Data) throws -> Value
|
||||||
/// Human-readable serializer name used in audit reports.
|
/// Human-readable serializer name used in audit reports.
|
||||||
|
///
|
||||||
|
/// Keep names stable so audit output is predictable and searchable.
|
||||||
public let name: String
|
public let name: String
|
||||||
|
|
||||||
/// Creates a custom serializer.
|
/// Creates a custom serializer.
|
||||||
@ -26,6 +35,8 @@ public struct Serializer<Value: Codable & Sendable>: Sendable, CustomStringConve
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Description used by `CustomStringConvertible`.
|
/// Description used by `CustomStringConvertible`.
|
||||||
|
///
|
||||||
|
/// Mirrors the `name` so logs and debug output show the configured serializer.
|
||||||
public var description: String { name }
|
public var description: String { name }
|
||||||
|
|
||||||
/// JSON serializer using `JSONEncoder` and `JSONDecoder`.
|
/// JSON serializer using `JSONEncoder` and `JSONDecoder`.
|
||||||
@ -70,6 +81,8 @@ public struct Serializer<Value: Codable & Sendable>: Sendable, CustomStringConve
|
|||||||
public extension Serializer where Value == Data {
|
public extension Serializer where Value == Data {
|
||||||
/// Serializer that passes through raw `Data`.
|
/// Serializer that passes through raw `Data`.
|
||||||
///
|
///
|
||||||
|
/// Use this when the caller already owns the encoding format.
|
||||||
|
///
|
||||||
/// - Returns: A serializer that returns `Data` unchanged.
|
/// - Returns: A serializer that returns `Data` unchanged.
|
||||||
static var data: Serializer<Value> {
|
static var data: Serializer<Value> {
|
||||||
Serializer<Value>(encode: { $0 }, decode: { $0 }, name: "data")
|
Serializer<Value>(encode: { $0 }, decode: { $0 }, name: "data")
|
||||||
|
|||||||
@ -1,17 +1,32 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Storage location for a ``StorageKey``.
|
/// 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 {
|
public enum StorageDomain: Sendable, Equatable {
|
||||||
/// Standard `UserDefaults` using the provided suite name.
|
/// Standard `UserDefaults` using the provided suite name.
|
||||||
|
///
|
||||||
|
/// Pass `nil` to target the default `UserDefaults` suite.
|
||||||
case userDefaults(suite: String?)
|
case userDefaults(suite: String?)
|
||||||
/// App group `UserDefaults` using the provided group identifier.
|
/// App group `UserDefaults` using the provided group identifier.
|
||||||
|
///
|
||||||
|
/// Use this for data shared with extensions on the same device.
|
||||||
case appGroupUserDefaults(identifier: String?)
|
case appGroupUserDefaults(identifier: String?)
|
||||||
/// Keychain storage using the provided service identifier.
|
/// Keychain storage using the provided service identifier.
|
||||||
|
///
|
||||||
|
/// Keychain storage is used for sensitive or credential-like data.
|
||||||
case keychain(service: String?)
|
case keychain(service: String?)
|
||||||
/// File system storage in the specified directory.
|
/// File system storage in the specified directory.
|
||||||
|
///
|
||||||
|
/// Suitable for larger values that should not live in defaults or Keychain.
|
||||||
case fileSystem(directory: FileDirectory)
|
case fileSystem(directory: FileDirectory)
|
||||||
/// Encrypted file system storage in the specified directory.
|
/// Encrypted file system storage in the specified directory.
|
||||||
|
///
|
||||||
|
/// Values are encrypted before being written to the file system.
|
||||||
case encryptedFileSystem(directory: FileDirectory)
|
case encryptedFileSystem(directory: FileDirectory)
|
||||||
/// App group file storage using the group identifier and directory.
|
/// 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)
|
case appGroupFileSystem(identifier: String?, directory: FileDirectory)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,37 +1,68 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Errors thrown by storage operations and migrations.
|
/// 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 {
|
public enum StorageError: Error, Equatable {
|
||||||
/// Failed to encode a value.
|
/// Failed to encode a value.
|
||||||
|
///
|
||||||
|
/// This indicates the serializer could not transform a value into `Data`.
|
||||||
case serializationFailed
|
case serializationFailed
|
||||||
/// Failed to decode stored data.
|
/// Failed to decode stored data.
|
||||||
|
///
|
||||||
|
/// This typically means the stored payload does not match the expected type.
|
||||||
case deserializationFailed
|
case deserializationFailed
|
||||||
/// Failed to apply or remove security for stored data.
|
/// Failed to apply or remove security for stored data.
|
||||||
|
///
|
||||||
|
/// This error is used when encryption or decryption fails.
|
||||||
case securityApplicationFailed
|
case securityApplicationFailed
|
||||||
/// Underlying Keychain error.
|
/// Underlying Keychain error.
|
||||||
|
///
|
||||||
|
/// The associated `OSStatus` comes from Security framework APIs.
|
||||||
case keychainError(OSStatus)
|
case keychainError(OSStatus)
|
||||||
/// File system error description.
|
/// 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
|
case fileError(String) // Changed from Error to String for easier Equatable conformance
|
||||||
/// A phone-only key was accessed on watchOS.
|
/// A phone-only key was accessed on watchOS.
|
||||||
|
///
|
||||||
|
/// The associated value is the key name.
|
||||||
case phoneOnlyKeyAccessedOnWatch(String)
|
case phoneOnlyKeyAccessedOnWatch(String)
|
||||||
/// A watch-only key was accessed on iOS.
|
/// A watch-only key was accessed on iOS.
|
||||||
|
///
|
||||||
|
/// The associated value is the key name.
|
||||||
case watchOnlyKeyAccessedOnPhone(String)
|
case watchOnlyKeyAccessedOnPhone(String)
|
||||||
/// Invalid UserDefaults suite name.
|
/// Invalid UserDefaults suite name.
|
||||||
|
///
|
||||||
|
/// The associated value is the invalid suite identifier.
|
||||||
case invalidUserDefaultsSuite(String)
|
case invalidUserDefaultsSuite(String)
|
||||||
/// Invalid App Group identifier.
|
/// Invalid App Group identifier.
|
||||||
|
///
|
||||||
|
/// The associated value is the invalid group identifier.
|
||||||
case invalidAppGroupIdentifier(String)
|
case invalidAppGroupIdentifier(String)
|
||||||
/// Sync payload exceeded the configured maximum size.
|
/// Sync payload exceeded the configured maximum size.
|
||||||
|
///
|
||||||
|
/// The key cannot be synced because the payload is too large.
|
||||||
case dataTooLargeForSync
|
case dataTooLargeForSync
|
||||||
/// No value exists for the requested key.
|
/// No value exists for the requested key.
|
||||||
case notFound
|
case notFound
|
||||||
/// The key is not registered in any catalog.
|
/// The key is not registered in any catalog.
|
||||||
|
///
|
||||||
|
/// The associated value is the missing key name.
|
||||||
case unregisteredKey(String)
|
case unregisteredKey(String)
|
||||||
/// Duplicate key names detected during registration.
|
/// Duplicate key names detected during registration.
|
||||||
|
///
|
||||||
|
/// The associated array lists the duplicate key names.
|
||||||
case duplicateRegisteredKeys([String])
|
case duplicateRegisteredKeys([String])
|
||||||
/// Missing or empty key description.
|
/// Missing or empty key description.
|
||||||
|
///
|
||||||
|
/// The associated value is the key name missing the description.
|
||||||
case missingDescription(String)
|
case missingDescription(String)
|
||||||
|
|
||||||
/// Compares two storage errors for equality.
|
/// Compares two storage errors for equality.
|
||||||
|
///
|
||||||
|
/// This custom equality handles associated values, including `OSStatus`.
|
||||||
public static func == (lhs: StorageError, rhs: StorageError) -> Bool {
|
public static func == (lhs: StorageError, rhs: StorageError) -> Bool {
|
||||||
switch (lhs, rhs) {
|
switch (lhs, rhs) {
|
||||||
case (.serializationFailed, .serializationFailed),
|
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 {}
|
extension StorageError: @unchecked Sendable {}
|
||||||
|
|||||||
@ -1,28 +1,54 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Snapshot of a ``StorageKey`` used for audit and registration.
|
/// 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 {
|
public struct StorageKeyDescriptor: Sendable, CustomStringConvertible {
|
||||||
/// Key name within its domain.
|
/// Key name within its domain.
|
||||||
|
///
|
||||||
|
/// This is the primary identifier used for duplicate detection.
|
||||||
public let name: String
|
public let name: String
|
||||||
/// Storage domain for the key.
|
/// Storage domain for the key.
|
||||||
|
///
|
||||||
|
/// The router uses this to determine which storage helper should be used.
|
||||||
public let domain: StorageDomain
|
public let domain: StorageDomain
|
||||||
/// Security policy applied to the key.
|
/// Security policy applied to the key.
|
||||||
|
///
|
||||||
|
/// Indicates whether encryption or Keychain placement is required.
|
||||||
public let security: SecurityPolicy
|
public let security: SecurityPolicy
|
||||||
/// Serializer name used for encoding/decoding.
|
/// Serializer name used for encoding/decoding.
|
||||||
|
///
|
||||||
|
/// This is recorded for audit output; the serializer itself remains on the key.
|
||||||
public let serializer: String
|
public let serializer: String
|
||||||
/// String representation of the value type.
|
/// String representation of the value type.
|
||||||
|
///
|
||||||
|
/// Useful for diagnostics when reviewing audit reports.
|
||||||
public let valueType: String
|
public let valueType: String
|
||||||
/// Owning module or feature name.
|
/// Owning module or feature name.
|
||||||
|
///
|
||||||
|
/// Use this to identify the feature responsible for the stored data.
|
||||||
public let owner: String
|
public let owner: String
|
||||||
/// Platform availability for the key.
|
/// Platform availability for the key.
|
||||||
|
///
|
||||||
|
/// Governs which runtime platforms are allowed to access the key.
|
||||||
public let availability: PlatformAvailability
|
public let availability: PlatformAvailability
|
||||||
/// Sync policy for WatchConnectivity.
|
/// Sync policy for WatchConnectivity.
|
||||||
|
///
|
||||||
|
/// Used when determining sync eligibility and constraints.
|
||||||
public let syncPolicy: SyncPolicy
|
public let syncPolicy: SyncPolicy
|
||||||
/// Human-readable description for audit reports.
|
/// Human-readable description for audit reports.
|
||||||
|
///
|
||||||
|
/// Descriptions are required to keep audit results interpretable.
|
||||||
public let description: String
|
public let description: String
|
||||||
/// Optional catalog name the key belongs to.
|
/// Optional catalog name the key belongs to.
|
||||||
|
///
|
||||||
|
/// Catalog names aid in grouping keys by feature or module.
|
||||||
public let catalog: String?
|
public let catalog: String?
|
||||||
|
|
||||||
|
/// Internal initializer used by factories and audits.
|
||||||
|
///
|
||||||
|
/// Callers should prefer `from(_:)` unless building descriptors manually.
|
||||||
init(
|
init(
|
||||||
name: String,
|
name: String,
|
||||||
domain: StorageDomain,
|
domain: StorageDomain,
|
||||||
|
|||||||
@ -1,11 +1,20 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Defines how a key participates in WatchConnectivity sync.
|
/// 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 {
|
public enum SyncPolicy: Sendable {
|
||||||
/// No sync behavior.
|
/// No sync behavior.
|
||||||
|
///
|
||||||
|
/// Use this for keys that should remain device-local.
|
||||||
case never
|
case never
|
||||||
/// Sync only when the app explicitly requests it.
|
/// Sync only when the app explicitly requests it.
|
||||||
|
///
|
||||||
|
/// Use this when you need full control over sync timing.
|
||||||
case manual
|
case manual
|
||||||
/// Automatically sync when data size is below the configured threshold.
|
/// 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
|
case automaticSmall
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
import Foundation
|
||||||
|
|
||||||
/// Migration protocol that combines multiple sources into a single destination.
|
/// Migration protocol that combines multiple sources into a single destination.
|
||||||
|
|||||||
@ -1,4 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Marker protocol for migrations that primarily use conditional checks.
|
|
||||||
public protocol ConditionalMigration: StorageMigration {}
|
|
||||||
@ -1,3 +1,6 @@
|
|||||||
|
/// Supplies encryption key material to support external key sources.
|
||||||
|
///
|
||||||
|
/// Conformers provide raw bytes used by ``SecurityPolicy.EncryptionPolicy.external``.
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Supplies external key material for encryption policies.
|
/// Supplies external key material for encryption policies.
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
/// Defines the Keychain operations required by the storage layer.
|
||||||
|
///
|
||||||
|
/// This protocol enables swapping concrete Keychain implementations for testing.
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Protocol defining the interface for Keychain operations.
|
/// Protocol defining the interface for Keychain operations.
|
||||||
|
|||||||
@ -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.
|
/// Collection of storage keys used for registration and auditing.
|
||||||
public protocol StorageKeyCatalog: Sendable {
|
public protocol StorageKeyCatalog: Sendable {
|
||||||
/// Human-readable catalog name used in audit reports.
|
/// Human-readable catalog name used in audit reports.
|
||||||
|
|||||||
@ -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
|
import Foundation
|
||||||
|
|
||||||
/// Core migration protocol for moving data into a destination ``StorageKey``.
|
/// Core migration protocol for moving data into a destination ``StorageKey``.
|
||||||
|
|||||||
@ -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
|
import Foundation
|
||||||
|
|
||||||
/// Abstraction for basic storage operations.
|
/// Abstraction for basic storage operations.
|
||||||
|
|||||||
@ -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
|
import Foundation
|
||||||
|
|
||||||
/// Migration protocol that transforms a source value into a destination value.
|
/// Migration protocol that transforms a source value into a destination value.
|
||||||
|
|||||||
@ -1,16 +1,23 @@
|
|||||||
import Foundation
|
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.
|
/// Central coordinator for all LocalData storage operations.
|
||||||
///
|
///
|
||||||
/// `StorageRouter` orchestrates serialization, security, storage domain routing,
|
/// `StorageRouter` orchestrates:
|
||||||
/// catalog validation, migrations, and WatchConnectivity sync. Use the shared
|
/// - serialization and deserialization through ``Serializer``
|
||||||
/// instance for app-wide access and register catalogs at launch to enable
|
/// - security policies through ``SecurityPolicy``
|
||||||
/// auditability and duplicate key detection.
|
/// - 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 {
|
public actor StorageRouter: StorageProviding {
|
||||||
|
|
||||||
/// Shared router instance for app-wide storage access.
|
/// 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()
|
public static let shared = StorageRouter()
|
||||||
|
|
||||||
private var catalogRegistries: [String: [AnyStorageKey]] = [:]
|
private var catalogRegistries: [String: [AnyStorageKey]] = [:]
|
||||||
@ -25,8 +32,15 @@ public actor StorageRouter: StorageProviding {
|
|||||||
|
|
||||||
/// Initializes a new router with injected helpers.
|
/// 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`.
|
/// - Important: Internal for testing isolation via `@testable import`.
|
||||||
/// Production code should use ``StorageRouter/shared``.
|
/// Production code should use ``StorageRouter/shared``.
|
||||||
internal init(
|
internal init(
|
||||||
keychain: KeychainStoring = KeychainHelper.shared,
|
keychain: KeychainStoring = KeychainHelper.shared,
|
||||||
encryption: EncryptionHelper = .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 {
|
internal func exists(descriptor: StorageKeyDescriptor) async throws -> Bool {
|
||||||
switch descriptor.domain {
|
switch descriptor.domain {
|
||||||
case .userDefaults(let suite):
|
case .userDefaults(let suite):
|
||||||
@ -291,6 +311,11 @@ public actor StorageRouter: StorageProviding {
|
|||||||
|
|
||||||
// MARK: - Platform Validation
|
// 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<Value>(for key: StorageKey<Value>) throws {
|
nonisolated private func validatePlatformAvailability<Value>(for key: StorageKey<Value>) throws {
|
||||||
#if os(watchOS)
|
#if os(watchOS)
|
||||||
if key.availability == .phoneOnly {
|
if key.availability == .phoneOnly {
|
||||||
@ -303,6 +328,10 @@ public actor StorageRouter: StorageProviding {
|
|||||||
#endif
|
#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<Value>(for key: StorageKey<Value>) throws {
|
private func validateCatalogRegistration<Value>(for key: StorageKey<Value>) throws {
|
||||||
guard !registeredKeys.isEmpty else { return }
|
guard !registeredKeys.isEmpty else { return }
|
||||||
guard registeredKeys[key.name] != nil else {
|
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 {
|
private var isRunningTests: Bool {
|
||||||
// Broad check for any test-related environment variables or classes
|
// Broad check for any test-related environment variables or classes
|
||||||
if ProcessInfo.processInfo.environment.keys.contains(where: {
|
if ProcessInfo.processInfo.environment.keys.contains(where: {
|
||||||
@ -327,6 +359,10 @@ public actor StorageRouter: StorageProviding {
|
|||||||
return NSClassFromString("XCTestCase") != nil || NSClassFromString("Testing.Test") != nil
|
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 {
|
private func validateUniqueKeys(_ entries: [AnyStorageKey]) throws {
|
||||||
var exactNames: [String: Int] = [:]
|
var exactNames: [String: Int] = [:]
|
||||||
var duplicates: [String] = []
|
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 {
|
private func validateDescription(_ entries: [AnyStorageKey]) throws {
|
||||||
let missing = entries
|
let missing = entries
|
||||||
.map(\.descriptor)
|
.map(\.descriptor)
|
||||||
@ -357,6 +397,11 @@ public actor StorageRouter: StorageProviding {
|
|||||||
|
|
||||||
// MARK: - Migration
|
// 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<Value>(for key: StorageKey<Value>) async throws -> Value? {
|
private func attemptMigration<Value>(for key: StorageKey<Value>) async throws -> Value? {
|
||||||
guard let migration = resolveMigration(for: key) else { return nil }
|
guard let migration = resolveMigration(for: key) else { return nil }
|
||||||
|
|
||||||
@ -380,18 +425,34 @@ public actor StorageRouter: StorageProviding {
|
|||||||
return nil
|
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<Value>(for key: StorageKey<Value>) -> AnyStorageMigration? {
|
private func resolveMigration<Value>(for key: StorageKey<Value>) -> AnyStorageMigration? {
|
||||||
key.migration
|
key.migration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds a migration context with the router's current history.
|
||||||
|
///
|
||||||
|
/// - Returns: A new ``MigrationContext`` populated with history.
|
||||||
internal func buildMigrationContext() -> MigrationContext {
|
internal func buildMigrationContext() -> MigrationContext {
|
||||||
MigrationContext(migrationHistory: migrationHistory)
|
MigrationContext(migrationHistory: migrationHistory)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Records a successful migration timestamp for a key.
|
||||||
|
///
|
||||||
|
/// - Parameter descriptor: Descriptor of the migrated key.
|
||||||
internal func recordMigration(for descriptor: StorageKeyDescriptor) {
|
internal func recordMigration(for descriptor: StorageKeyDescriptor) {
|
||||||
migrationHistory[descriptor.name] = Date()
|
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<Value>(
|
internal func shouldAllowMigration<Value>(
|
||||||
for key: StorageKey<Value>,
|
for key: StorageKey<Value>,
|
||||||
context: MigrationContext
|
context: MigrationContext
|
||||||
@ -409,6 +470,13 @@ public actor StorageRouter: StorageProviding {
|
|||||||
|
|
||||||
// MARK: - Serialization
|
// 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: Codable & Sendable>(
|
private func serialize<Value: Codable & Sendable>(
|
||||||
_ value: Value,
|
_ value: Value,
|
||||||
with serializer: Serializer<Value>
|
with serializer: Serializer<Value>
|
||||||
@ -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<Value: Codable & Sendable>(
|
nonisolated internal func deserialize<Value: Codable & Sendable>(
|
||||||
_ data: Data,
|
_ data: Data,
|
||||||
with serializer: Serializer<Value>
|
with serializer: Serializer<Value>
|
||||||
@ -433,6 +508,14 @@ public actor StorageRouter: StorageProviding {
|
|||||||
|
|
||||||
// MARK: - Security
|
// 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(
|
internal func applySecurity(
|
||||||
_ data: Data,
|
_ data: Data,
|
||||||
for descriptor: StorageKeyDescriptor,
|
for descriptor: StorageKeyDescriptor,
|
||||||
@ -466,10 +549,21 @@ public actor StorageRouter: StorageProviding {
|
|||||||
|
|
||||||
// MARK: - Storage Operations
|
// MARK: - Storage Operations
|
||||||
|
|
||||||
|
/// Stores secured data for a typed key.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - data: The secured data to store.
|
||||||
|
/// - key: The destination key.
|
||||||
private func store<Value>(_ data: Data, for key: StorageKey<Value>) async throws {
|
private func store<Value>(_ data: Data, for key: StorageKey<Value>) async throws {
|
||||||
try await store(data, for: .from(key))
|
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 {
|
private func store(_ data: Data, for descriptor: StorageKeyDescriptor) async throws {
|
||||||
switch descriptor.domain {
|
switch descriptor.domain {
|
||||||
case .userDefaults(let suite):
|
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? {
|
internal func retrieve(for descriptor: StorageKeyDescriptor) async throws -> Data? {
|
||||||
switch descriptor.domain {
|
switch descriptor.domain {
|
||||||
case .userDefaults(let suite):
|
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 {
|
internal func delete(for descriptor: StorageKeyDescriptor) async throws {
|
||||||
switch descriptor.domain {
|
switch descriptor.domain {
|
||||||
case .userDefaults(let suite):
|
case .userDefaults(let suite):
|
||||||
@ -570,6 +673,12 @@ public actor StorageRouter: StorageProviding {
|
|||||||
|
|
||||||
// MARK: - Sync
|
// 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<Value>(_ key: StorageKey<Value>, data: Data) async throws {
|
private func handleSync<Value>(_ key: StorageKey<Value>, data: Data) async throws {
|
||||||
try await sync.syncIfNeeded(
|
try await sync.syncIfNeeded(
|
||||||
data: data,
|
data: data,
|
||||||
@ -663,7 +772,12 @@ public actor StorageRouter: StorageProviding {
|
|||||||
// MARK: - Internal Sync Handling
|
// MARK: - Internal Sync Handling
|
||||||
|
|
||||||
/// Internal method to update storage from received sync data.
|
/// 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 {
|
func updateFromSync(keyName: String, data: Data) async throws {
|
||||||
// Find the registered entry for this key
|
// Find the registered entry for this key
|
||||||
guard let entry = registeredKeys[keyName] else {
|
guard let entry = registeredKeys[keyName] else {
|
||||||
@ -679,6 +793,11 @@ public actor StorageRouter: StorageProviding {
|
|||||||
|
|
||||||
// MARK: - Resolution Helpers
|
// 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 {
|
private func resolveService(_ service: String?) throws -> String {
|
||||||
guard let resolved = service ?? storageConfiguration.defaultKeychainService else {
|
guard let resolved = service ?? storageConfiguration.defaultKeychainService else {
|
||||||
Logger.error("No keychain service provided and no default configured")
|
Logger.error("No keychain service provided and no default configured")
|
||||||
@ -688,6 +807,11 @@ public actor StorageRouter: StorageProviding {
|
|||||||
return resolved
|
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 {
|
private func resolveIdentifier(_ identifier: String?) throws -> String {
|
||||||
guard let resolved = identifier ?? storageConfiguration.defaultAppGroupIdentifier else {
|
guard let resolved = identifier ?? storageConfiguration.defaultAppGroupIdentifier else {
|
||||||
Logger.error("No App Group identifier provided and no default configured")
|
Logger.error("No App Group identifier provided and no default configured")
|
||||||
|
|||||||
@ -21,6 +21,7 @@ public struct DeviceInfo: Sendable {
|
|||||||
/// Current device info derived from the running environment.
|
/// Current device info derived from the running environment.
|
||||||
public static let current = DeviceInfo()
|
public static let current = DeviceInfo()
|
||||||
|
|
||||||
|
/// Builds a snapshot of the current device environment.
|
||||||
private init() {
|
private init() {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
self.platform = .iOS
|
self.platform = .iOS
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user