From 905cabc542830e83a347a0bbe606d2c553bfeb5a Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Thu, 15 Jan 2026 11:02:48 -0600 Subject: [PATCH] Update Audit, Helpers, Services and tests, docs Summary: - Sources: update Audit, Helpers, Services - Tests: update tests for StorageCatalogTests.swift - Docs: add docs for Design, Migration, Testing Stats: - 10 files changed, 181 insertions(+), 1 deletion(-) --- Documentation/Design.md | 38 ++++++++++++++ Documentation/Migration.md | 35 +++++++++++++ Documentation/Testing.md | 33 ++++++++++++ .../StorageAuditReport.swift | 2 +- .../EncryptionHelper.swift | 0 .../FileStorageHelper.swift | 0 .../KeychainHelper.swift | 0 .../{Services => Helpers}/SyncHelper.swift | 50 +++++++++++++++++++ .../UserDefaultsHelper.swift | 0 .../LocalDataTests/StorageCatalogTests.swift | 24 +++++++++ 10 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 Documentation/Design.md create mode 100644 Documentation/Migration.md create mode 100644 Documentation/Testing.md rename Sources/LocalData/{Services => Audit}/StorageAuditReport.swift (98%) rename Sources/LocalData/{Services => Helpers}/EncryptionHelper.swift (100%) rename Sources/LocalData/{Services => Helpers}/FileStorageHelper.swift (100%) rename Sources/LocalData/{Services => Helpers}/KeychainHelper.swift (100%) rename Sources/LocalData/{Services => Helpers}/SyncHelper.swift (65%) rename Sources/LocalData/{Services => Helpers}/UserDefaultsHelper.swift (100%) diff --git a/Documentation/Design.md b/Documentation/Design.md new file mode 100644 index 0000000..1613ac9 --- /dev/null +++ b/Documentation/Design.md @@ -0,0 +1,38 @@ +# LocalData Architecture and Design + +## Overview +`LocalData` is a typed, discoverable namespace for persisted application data. It provides a consistent API for reading, writing, and removing data across multiple storage domains while enforcing security and serialization policies. + +## Key Components + +### StorageRouter +The central `actor` that coordinates all storage operations. It acts as the primary API surface and handles routing, catalog validation, and migration. + +### StorageKey +A protocol that defines the metadata for a single piece of persistent data. +- **Value**: The type of the data (Codable). +- **Domain**: Where the data is stored (UserDefaults, Keychain, FileSystem, etc.). +- **Security**: How the data is secured (None, Keychain-native, or custom Encryption). +- **Serializer**: How the data is encoded to/from `Data` (JSON, Plist, etc.). +- **SyncPolicy**: Rules for syncing data between iPhone and Watch. + +### Helper Actors +Specialized actors for each storage domain: +- `KeychainHelper`: Manages Keychain operations. +- `UserDefaultsHelper`: Manages UserDefaults and App Group defaults. +- `FileStorageHelper`: Manages local and App Group file storage. +- `EncryptionHelper`: Provides AES and ChaCha20 encryption. +- `SyncHelper`: Manages WatchConnectivity synchronization. + +## Routing Logic +1. **Validation**: Check if the key is registered in the catalog (if registered) and if it's available on the current platform. +2. **Serialization**: Convert the value to `Data` using the specified serializer. +3. **Security (Apply)**: Apply encryption or security policies. +4. **Storage**: Delegate the write operation to the appropriate helper. +5. **Sync**: Trigger a sync update if the policy allows. + +## Security Model +- **None**: Data is stored as-is (e.g., standard UserDefaults). +- **Keychain**: Native hardware security using the iOS Keychain. +- **Encrypted**: Custom encryption (AES-256-GCM or ChaCha20-Poly1305) with key derivation (PBKDF2/HKDF). +- **File Protection**: Uses iOS "Complete File Protection" for encrypted file system writes. diff --git a/Documentation/Migration.md b/Documentation/Migration.md new file mode 100644 index 0000000..c85e0f5 --- /dev/null +++ b/Documentation/Migration.md @@ -0,0 +1,35 @@ +# LocalData Migration Guide + +## Overview +`LocalData` provides built-in support for migrating data from legacy storage locations or keys to modern `StorageKey` definitions. + +## Automatic Migration +When calling `get(_:)` on a key, the `StorageRouter` automatically: +1. Checks the primary location. +2. If not found, iterates through `migrationSources` defined on the key. +3. If data is found in a source: + - Unsecures it using the source's old policy. + - Re-secures it using the new key's policy. + - Stores it in the new location. + - Deletes the legacy data. + - Returns the value. + +## Proactive Migration (Sweep) +You can trigger a sweep of all registered keys at app launch: +```swift +try await StorageRouter.shared.registerCatalog(MyCatalog.self, migrateImmediately: true) +``` +This iterates through all keys in the catalog and calls `migrate(for:)` on each, ensuring all legacy data is consolidated. + +## Defining Migration Sources +When defining a `StorageKey`, add legacy descriptors to the `migrationSources` array: +```swift +struct MyNewKey: StorageKey { + // ... + var migrationSources: [AnyStorageKey] { + [ + .key(LegacyKey(name: "old_key_name", domain: .userDefaults(suite: nil))) + ] + } +} +``` diff --git a/Documentation/Testing.md b/Documentation/Testing.md new file mode 100644 index 0000000..cde6a98 --- /dev/null +++ b/Documentation/Testing.md @@ -0,0 +1,33 @@ +# LocalData Testing Strategy + +## Goal +To ensure high reliability for data persistence, security, and migration across all supported platforms (iOS and watchOS). + +## Test Suites + +### Unit Tests (`Tests/LocalDataTests/`) +- **LocalDataTests.swift**: Core round-trip tests for each storage domain (UserDefaults, FileSystem). +- **KeychainHelperTests.swift**: Verification of Keychain API interactions (add, update, delete, exists). +- **EncryptionHelperTests.swift**: Round-trip tests for AES and ChaCha20 encryption/decryption with various key derivation methods. +- **StorageCatalogTests.swift**: Validation of catalog registration, duplicate detection, and missing description checks. + +## Key Testing Patterns + +### 1. Domain Round-Trips +Always test the full cycle: `set` -> `get` (compare) -> `remove` -> `get` (expect `notFound`). + +### 2. Migration Tests +Simulate legacy data by writing to a "legacy" key first, then verifying that the "modern" key can retrieve and consolidate that data. + +### 3. Error Handling +Verify that the correct `StorageError` is thrown for: +- `notFound` +- `unregisteredKey` +- `dataTooLargeForSync` +- Domain-specific failures (e.g., Keychain errors) + +## Running Tests +Run all tests from the package root: +```bash +swift test +``` diff --git a/Sources/LocalData/Services/StorageAuditReport.swift b/Sources/LocalData/Audit/StorageAuditReport.swift similarity index 98% rename from Sources/LocalData/Services/StorageAuditReport.swift rename to Sources/LocalData/Audit/StorageAuditReport.swift index 050f144..5febcaf 100644 --- a/Sources/LocalData/Services/StorageAuditReport.swift +++ b/Sources/LocalData/Audit/StorageAuditReport.swift @@ -104,7 +104,7 @@ public struct StorageAuditReport: Sendable { private static func string(for derivation: SecurityPolicy.KeyDerivation) -> String { switch derivation { case .pbkdf2(let iterations, _): - return "pbkdf2(\(iterations))" + return "pbkdf2(\(iterations ?? 0))" case .hkdf: return "hkdf" } diff --git a/Sources/LocalData/Services/EncryptionHelper.swift b/Sources/LocalData/Helpers/EncryptionHelper.swift similarity index 100% rename from Sources/LocalData/Services/EncryptionHelper.swift rename to Sources/LocalData/Helpers/EncryptionHelper.swift diff --git a/Sources/LocalData/Services/FileStorageHelper.swift b/Sources/LocalData/Helpers/FileStorageHelper.swift similarity index 100% rename from Sources/LocalData/Services/FileStorageHelper.swift rename to Sources/LocalData/Helpers/FileStorageHelper.swift diff --git a/Sources/LocalData/Services/KeychainHelper.swift b/Sources/LocalData/Helpers/KeychainHelper.swift similarity index 100% rename from Sources/LocalData/Services/KeychainHelper.swift rename to Sources/LocalData/Helpers/KeychainHelper.swift diff --git a/Sources/LocalData/Services/SyncHelper.swift b/Sources/LocalData/Helpers/SyncHelper.swift similarity index 65% rename from Sources/LocalData/Services/SyncHelper.swift rename to Sources/LocalData/Helpers/SyncHelper.swift index 4917a94..4b57545 100644 --- a/Sources/LocalData/Services/SyncHelper.swift +++ b/Sources/LocalData/Helpers/SyncHelper.swift @@ -105,6 +105,10 @@ actor SyncHelper { guard WCSession.isSupported() else { return } let session = WCSession.default + if session.delegate == nil { + setupSession() + } + guard session.activationState == .activated else { return } #if os(iOS) @@ -113,5 +117,51 @@ actor SyncHelper { try session.updateApplicationContext([keyName: data]) } + + private func setupSession() { + let session = WCSession.default + session.delegate = SessionDelegateProxy.shared + session.activate() + } + + /// Handles received application context from the paired device. + /// This is called by the delegate proxy. + fileprivate func handleReceivedContext(_ context: [String: Any]) async { + Logger.info(">>> [SYNC] Received application context with \(context.count) keys") + for (key, value) in context { + guard let data = value as? Data else { continue } + Logger.debug(">>> [SYNC] Processing received data for key: \(key)") + // Future implementation: Route this back to StorageRouter to update local storage + // For now, we just log it as a skeleton implementation + } + } #endif } + +#if os(iOS) || os(watchOS) +/// A private proxy class to handle WCSessionDelegate callbacks and route them to the SyncHelper actor. +private final class SessionDelegateProxy: NSObject, WCSessionDelegate { + static let shared = SessionDelegateProxy() + + func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { + if let error = error { + Logger.error("WCSession activation failed: \(error.localizedDescription)") + } else { + Logger.info("WCSession activated with state: \(activationState.rawValue)") + } + } + + func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + Task { + await SyncHelper.shared.handleReceivedContext(applicationContext) + } + } + + #if os(iOS) + func sessionDidBecomeInactive(_ session: WCSession) {} + func sessionDidDeactivate(_ session: WCSession) { + session.activate() + } + #endif +} +#endif diff --git a/Sources/LocalData/Services/UserDefaultsHelper.swift b/Sources/LocalData/Helpers/UserDefaultsHelper.swift similarity index 100% rename from Sources/LocalData/Services/UserDefaultsHelper.swift rename to Sources/LocalData/Helpers/UserDefaultsHelper.swift diff --git a/Tests/LocalDataTests/StorageCatalogTests.swift b/Tests/LocalDataTests/StorageCatalogTests.swift index 45f3d34..37331e6 100644 --- a/Tests/LocalDataTests/StorageCatalogTests.swift +++ b/Tests/LocalDataTests/StorageCatalogTests.swift @@ -46,6 +46,14 @@ private struct EmptyCatalog: StorageKeyCatalog { static var allKeys: [AnyStorageKey] { [] } } +private struct MissingDescriptionCatalog: StorageKeyCatalog { + static var allKeys: [AnyStorageKey] { + [ + .key(TestCatalogKey(name: "missing.desc", description: " ")) + ] + } +} + // MARK: - Tests struct StorageCatalogTests { @@ -92,4 +100,20 @@ struct StorageCatalogTests { try await StorageRouter.shared.registerCatalog(DuplicateNameCatalog.self) } } + + @Test func catalogRegistrationDetectsMissingDescriptions() async { + // Attempting to register a catalog with missing descriptions should throw + await #expect(throws: StorageError.self) { + try await StorageRouter.shared.registerCatalog(MissingDescriptionCatalog.self) + } + } + + @Test func migrateAllRegisteredKeysInvokesMigrationOnKeys() async throws { + // This test verifies that migrateAllRegisteredKeys calling logic works. + // We'll use the shared StorageRouter and register a clean catalog first. + try await StorageRouter.shared.registerCatalog(ValidCatalog.self) + + // No error should occur + try await StorageRouter.shared.migrateAllRegisteredKeys() + } }