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(-)
This commit is contained in:
Matt Bruce 2026-01-15 11:02:48 -06:00
parent 27e47ae254
commit 905cabc542
10 changed files with 181 additions and 1 deletions

38
Documentation/Design.md Normal file
View File

@ -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.

View File

@ -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)))
]
}
}
```

33
Documentation/Testing.md Normal file
View File

@ -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
```

View File

@ -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"
}

View File

@ -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

View File

@ -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()
}
}