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