From b2616b8caad2c27e619196c4418ead074edc789a Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Tue, 13 Jan 2026 21:13:17 -0600 Subject: [PATCH] Add LocalData.swift, Models, Protocols (+1 more) and tests, docs, config Summary: - Sources: add LocalData.swift, Models, Protocols (+1 more) - Tests: add tests for LocalDataTests.swift - Docs: add docs for Proposal, README - Config: add Package - Other: add .gitignore Stats: - 19 files changed, 814 insertions(+) --- .gitignore | 3 + Package.swift | 27 ++ Proposal.md | 37 ++ README.md | 26 ++ Sources/LocalData/LocalData.swift | 5 + Sources/LocalData/Models/AnyCodable.swift | 50 +++ Sources/LocalData/Models/FileDirectory.swift | 15 + .../Models/KeychainAccessControl.swift | 27 ++ .../Models/KeychainAccessibility.swift | 15 + .../Models/PlatformAvailability.swift | 8 + Sources/LocalData/Models/SecurityPolicy.swift | 17 + Sources/LocalData/Models/Serializer.swift | 41 ++ Sources/LocalData/Models/StorageDomain.swift | 8 + Sources/LocalData/Models/StorageError.swift | 16 + Sources/LocalData/Models/SyncPolicy.swift | 7 + Sources/LocalData/Protocols/StorageKey.swift | 14 + .../Protocols/StorageProviding.swift | 7 + .../LocalData/Services/StorageRouter.swift | 390 ++++++++++++++++++ Tests/LocalDataTests/LocalDataTests.swift | 101 +++++ 19 files changed, 814 insertions(+) create mode 100644 .gitignore create mode 100644 Package.swift create mode 100644 Proposal.md create mode 100644 README.md create mode 100644 Sources/LocalData/LocalData.swift create mode 100644 Sources/LocalData/Models/AnyCodable.swift create mode 100644 Sources/LocalData/Models/FileDirectory.swift create mode 100644 Sources/LocalData/Models/KeychainAccessControl.swift create mode 100644 Sources/LocalData/Models/KeychainAccessibility.swift create mode 100644 Sources/LocalData/Models/PlatformAvailability.swift create mode 100644 Sources/LocalData/Models/SecurityPolicy.swift create mode 100644 Sources/LocalData/Models/Serializer.swift create mode 100644 Sources/LocalData/Models/StorageDomain.swift create mode 100644 Sources/LocalData/Models/StorageError.swift create mode 100644 Sources/LocalData/Models/SyncPolicy.swift create mode 100644 Sources/LocalData/Protocols/StorageKey.swift create mode 100644 Sources/LocalData/Protocols/StorageProviding.swift create mode 100644 Sources/LocalData/Services/StorageRouter.swift create mode 100644 Tests/LocalDataTests/LocalDataTests.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..29a1e94 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.build/ +.swiftpm/ +.DS_Store diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..dbabbff --- /dev/null +++ b/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "LocalData", + platforms: [ + .iOS(.v17), + .watchOS(.v10) + ], + products: [ + .library( + name: "LocalData", + targets: ["LocalData"] + ), + ], + targets: [ + .target( + name: "LocalData" + ), + .testTarget( + name: "LocalDataTests", + dependencies: ["LocalData"] + ), + ] +) diff --git a/Proposal.md b/Proposal.md new file mode 100644 index 0000000..bfdef15 --- /dev/null +++ b/Proposal.md @@ -0,0 +1,37 @@ +# LocalData Package Proposal + +## Goal +Create a single, typed, discoverable namespace for persisted app data with consistent security guarantees and clear ownership. This makes it obvious what is stored, where it is stored, how it is secured, how it is serialized, who owns it, and which platforms it belongs to or should sync to. + +## Package Placement +- localPackages/LocalData/ + - Sources/LocalData/ + - Tests/LocalDataTests/ + +## Dependencies +- Foundation +- Security (Keychain) +- CryptoKit (encryption) +- WatchConnectivity (sync helpers) + +## Implemented Scope +- StorageKey protocol and StorageKeys namespace for app-defined keys +- StorageRouter actor with async set, get, and remove +- StorageDomain options for user defaults, keychain, file system, and encrypted file system +- SecurityPolicy options for none, keychain, and encrypted data +- Serializer for JSON, property lists, raw Data, or custom encode and decode +- PlatformAvailability and SyncPolicy metadata +- AnyCodable for simple mixed-type payloads + +## Usage Pattern +Apps extend StorageKeys with their own key types and use StorageRouter.shared. This follows the Notification.Name pattern for discoverable keys. + +## Sync Behavior +StorageRouter can call WCSession.updateApplicationContext for manual or automaticSmall sync policies when availability allows it. Session activation and receiving data are owned by the app. + +## Future Ideas (Not Implemented) +- Migration helpers for legacy storage +- Key rotation strategies for encrypted data +- Watch-optimized data representations + +Any future changes should keep LocalData documentation in sync with code changes. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e8b7754 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# LocalData + +LocalData provides a typed, discoverable namespace for persisted app data across UserDefaults, Keychain, and file storage with optional encryption. + +## What ships in the package +- StorageKey protocol and StorageKeys namespace for app-defined keys +- StorageRouter actor (StorageProviding) with async set/get/remove +- StorageDomain options for user defaults, keychain, and file storage +- SecurityPolicy options for none, keychain, or encrypted data +- Serializer for JSON, property lists, raw Data, or custom encode and decode +- PlatformAvailability and SyncPolicy metadata for watch behavior +- AnyCodable utility for structured payloads + +## Usage +Define keys in your app by extending StorageKeys. Use StorageRouter.shared to set, get, and remove values. See SecureStorgageSample/SecureStorgageSample for working examples. + +## Sync behavior +StorageRouter can call WCSession.updateApplicationContext when SyncPolicy is manual or automaticSmall and availability is all or phoneWithWatchSync. The app is responsible for activating WCSession and handling incoming updates. + +## Platforms +- iOS 17+ +- watchOS 10+ + +## Notes +- LocalData does not include sample key definitions or models. +- Keys should be stable and unique per domain or service. diff --git a/Sources/LocalData/LocalData.swift b/Sources/LocalData/LocalData.swift new file mode 100644 index 0000000..93173e5 --- /dev/null +++ b/Sources/LocalData/LocalData.swift @@ -0,0 +1,5 @@ +// The Swift Programming Language +// https://docs.swift.org/swift-book +public enum StorageKeys { + +} diff --git a/Sources/LocalData/Models/AnyCodable.swift b/Sources/LocalData/Models/AnyCodable.swift new file mode 100644 index 0000000..a1642e8 --- /dev/null +++ b/Sources/LocalData/Models/AnyCodable.swift @@ -0,0 +1,50 @@ +import Foundation + +public struct AnyCodable: Codable, @unchecked Sendable { + public let value: Any + + public init(_ value: Any) { + self.value = value + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if let bool = try? container.decode(Bool.self) { + value = bool + } else if let int = try? container.decode(Int.self) { + value = int + } else if let double = try? container.decode(Double.self) { + value = double + } else if let string = try? container.decode(String.self) { + value = string + } else if let array = try? container.decode([AnyCodable].self) { + value = array.map { $0.value } + } else if let dictionary = try? container.decode([String: AnyCodable].self) { + value = dictionary.mapValues { $0.value } + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "AnyCodable value cannot be decoded") + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch value { + case let bool as Bool: + try container.encode(bool) + case let int as Int: + try container.encode(int) + case let double as Double: + try container.encode(double) + case let string as String: + try container.encode(string) + case let array as [Any]: + try container.encode(array.map { AnyCodable($0) }) + case let dictionary as [String: Any]: + try container.encode(dictionary.mapValues { AnyCodable($0) }) + default: + throw EncodingError.invalidValue(value, EncodingError.Context(codingPath: container.codingPath, debugDescription: "AnyCodable value cannot be encoded")) + } + } +} diff --git a/Sources/LocalData/Models/FileDirectory.swift b/Sources/LocalData/Models/FileDirectory.swift new file mode 100644 index 0000000..fd00524 --- /dev/null +++ b/Sources/LocalData/Models/FileDirectory.swift @@ -0,0 +1,15 @@ +import Foundation + +public enum FileDirectory: Sendable { + case documents, caches, custom(URL) + public func url() -> URL { + switch self { + case .documents: + return URL.documentsDirectory + case .caches: + return URL.cachesDirectory + case .custom(let url): + return url + } + } +} diff --git a/Sources/LocalData/Models/KeychainAccessControl.swift b/Sources/LocalData/Models/KeychainAccessControl.swift new file mode 100644 index 0000000..4c6e9c4 --- /dev/null +++ b/Sources/LocalData/Models/KeychainAccessControl.swift @@ -0,0 +1,27 @@ +import Foundation +import Security + +public enum KeychainAccessControl: Sendable { + case userPresence + // Add more as needed + + func accessControl(accessibility: KeychainAccessibility) -> SecAccessControl? { + let accessibilityValue: CFString + switch accessibility { + case .whenUnlocked: + accessibilityValue = kSecAttrAccessibleWhenUnlocked + case .afterFirstUnlock: + accessibilityValue = kSecAttrAccessibleAfterFirstUnlock + } + + switch self { + case .userPresence: + return SecAccessControlCreateWithFlags( + nil, + accessibilityValue, + .userPresence, + nil + ) + } + } +} diff --git a/Sources/LocalData/Models/KeychainAccessibility.swift b/Sources/LocalData/Models/KeychainAccessibility.swift new file mode 100644 index 0000000..525b866 --- /dev/null +++ b/Sources/LocalData/Models/KeychainAccessibility.swift @@ -0,0 +1,15 @@ +import Foundation +import Security + +public enum KeychainAccessibility: Sendable { + case whenUnlocked + case afterFirstUnlock + // Add more as needed + + var cfString: CFString { + switch self { + case .whenUnlocked: return kSecAttrAccessibleWhenUnlocked + case .afterFirstUnlock: return kSecAttrAccessibleAfterFirstUnlock + } + } +} diff --git a/Sources/LocalData/Models/PlatformAvailability.swift b/Sources/LocalData/Models/PlatformAvailability.swift new file mode 100644 index 0000000..3ddcd55 --- /dev/null +++ b/Sources/LocalData/Models/PlatformAvailability.swift @@ -0,0 +1,8 @@ +import Foundation + +public enum PlatformAvailability: Sendable { + case all // iPhone + Watch (small only!) + case phoneOnly // iPhone only (large/sensitive) + case watchOnly // Watch local only + case phoneWithWatchSync // Small data for explicit sync +} diff --git a/Sources/LocalData/Models/SecurityPolicy.swift b/Sources/LocalData/Models/SecurityPolicy.swift new file mode 100644 index 0000000..aeef9a1 --- /dev/null +++ b/Sources/LocalData/Models/SecurityPolicy.swift @@ -0,0 +1,17 @@ +import Foundation +import CryptoKit +import Security + +public enum SecurityPolicy: Sendable { + case none + case encrypted(EncryptionPolicy) + case keychain(accessibility: KeychainAccessibility, accessControl: KeychainAccessControl?) + + public enum EncryptionPolicy: Sendable { + case aes256(keyDerivation: KeyDerivation) + } + + public enum KeyDerivation: Sendable { + case pbkdf2(iterations: Int, salt: Data? = nil) + } +} diff --git a/Sources/LocalData/Models/Serializer.swift b/Sources/LocalData/Models/Serializer.swift new file mode 100644 index 0000000..a1493f2 --- /dev/null +++ b/Sources/LocalData/Models/Serializer.swift @@ -0,0 +1,41 @@ +import Foundation + +public struct Serializer: Sendable { + public let encode: @Sendable (Value) throws -> Data + public let decode: @Sendable (Data) throws -> Value + + public init( + encode: @escaping @Sendable (Value) throws -> Data, + decode: @escaping @Sendable (Data) throws -> Value + ) { + self.encode = encode + self.decode = decode + } + + public static var json: Serializer { + Serializer( + encode: { try JSONEncoder().encode($0) }, + decode: { try JSONDecoder().decode(Value.self, from: $0) } + ) + } + + public static var plist: Serializer { + Serializer( + encode: { try PropertyListEncoder().encode($0) }, + decode: { try PropertyListDecoder().decode(Value.self, from: $0) } + ) + } + + public static func custom( + encode: @escaping @Sendable (Value) throws -> Data, + decode: @escaping @Sendable (Data) throws -> Value + ) -> Serializer { + Serializer(encode: encode, decode: decode) + } +} + +public extension Serializer where Value == Data { + static var data: Serializer { + Serializer(encode: { $0 }, decode: { $0 }) + } +} diff --git a/Sources/LocalData/Models/StorageDomain.swift b/Sources/LocalData/Models/StorageDomain.swift new file mode 100644 index 0000000..eab3e04 --- /dev/null +++ b/Sources/LocalData/Models/StorageDomain.swift @@ -0,0 +1,8 @@ +import Foundation + +public enum StorageDomain: Sendable { + case userDefaults(suite: String?) + case keychain(service: String) + case fileSystem(directory: FileDirectory) + case encryptedFileSystem(directory: FileDirectory) +} diff --git a/Sources/LocalData/Models/StorageError.swift b/Sources/LocalData/Models/StorageError.swift new file mode 100644 index 0000000..fc1901a --- /dev/null +++ b/Sources/LocalData/Models/StorageError.swift @@ -0,0 +1,16 @@ +import Foundation + +public enum StorageError: Error { + case serializationFailed, deserializationFailed + case securityApplicationFailed + case keychainError(OSStatus) + case fileError(Error) + case phoneOnlyKeyAccessedOnWatch(String) + case watchOnlyKeyAccessedOnPhone(String) + case invalidUserDefaultsSuite(String) + case dataTooLargeForSync + case notFound + // ... +} + +extension StorageError: @unchecked Sendable {} diff --git a/Sources/LocalData/Models/SyncPolicy.swift b/Sources/LocalData/Models/SyncPolicy.swift new file mode 100644 index 0000000..355ebfc --- /dev/null +++ b/Sources/LocalData/Models/SyncPolicy.swift @@ -0,0 +1,7 @@ +import Foundation + +public enum SyncPolicy: Sendable { + case never // Default for most + case manual // Manual WCSession send + case automaticSmall // Auto-sync if small +} diff --git a/Sources/LocalData/Protocols/StorageKey.swift b/Sources/LocalData/Protocols/StorageKey.swift new file mode 100644 index 0000000..a13f305 --- /dev/null +++ b/Sources/LocalData/Protocols/StorageKey.swift @@ -0,0 +1,14 @@ +import Foundation + +public protocol StorageKey: Sendable { + associatedtype Value: Codable & Sendable + + var name: String { get } + var domain: StorageDomain { get } + var security: SecurityPolicy { get } + var serializer: Serializer { get } + var owner: String { get } + + var availability: PlatformAvailability { get } + var syncPolicy: SyncPolicy { get } +} diff --git a/Sources/LocalData/Protocols/StorageProviding.swift b/Sources/LocalData/Protocols/StorageProviding.swift new file mode 100644 index 0000000..5cdffac --- /dev/null +++ b/Sources/LocalData/Protocols/StorageProviding.swift @@ -0,0 +1,7 @@ +import Foundation + +public protocol StorageProviding: Sendable { + func set(_ value: Key.Value, for key: Key) async throws + func get(_ key: Key) async throws -> Key.Value + func remove(_ key: Key) async throws +} diff --git a/Sources/LocalData/Services/StorageRouter.swift b/Sources/LocalData/Services/StorageRouter.swift new file mode 100644 index 0000000..a6a3e2d --- /dev/null +++ b/Sources/LocalData/Services/StorageRouter.swift @@ -0,0 +1,390 @@ +import Foundation +import CryptoKit +import Security +#if os(iOS) || os(watchOS) +import WatchConnectivity +#endif + +public actor StorageRouter: StorageProviding { + public static let shared = StorageRouter() + + private enum EncryptionConstants { + static let masterKeyService = "LocalData.MasterKey" + static let masterKeyAccount = "LocalData.MasterKey" + static let masterKeyLength = 32 + } + + private init() {} + + public func set(_ value: Key.Value, for key: Key) async throws { + try validatePlatformAvailability(for: key) + + let data = try serialize(value, with: key.serializer) + + let securedData = try applySecurity(data, for: key, isEncrypt: true) + + try await store(securedData, for: key) + + try await handleSync(key, data: securedData) + } + + public func get(_ key: Key) async throws -> Key.Value { + try validatePlatformAvailability(for: key) + + guard let securedData = try await retrieve(for: key) else { + throw StorageError.notFound + } + + let data = try applySecurity(securedData, for: key, isEncrypt: false) + + return try deserialize(data, with: key.serializer) + } + + public func remove(_ key: Key) async throws { + try validatePlatformAvailability(for: key) + + try await delete(for: key) + } + + // MARK: - Platform Validation + + private func validatePlatformAvailability(for key: Key) throws { + #if os(watchOS) + if key.availability == .phoneOnly { + throw StorageError.phoneOnlyKeyAccessedOnWatch(key.name) + } + #elseif os(iOS) + if key.availability == .watchOnly { + throw StorageError.watchOnlyKeyAccessedOnPhone(key.name) + } + #endif + } + + // MARK: - Serialization + + private func serialize(_ value: Value, with serializer: Serializer) throws -> Data { + do { + return try serializer.encode(value) + } catch { + throw StorageError.serializationFailed + } + } + + private func deserialize(_ data: Data, with serializer: Serializer) throws -> Value { + do { + return try serializer.decode(data) + } catch { + throw StorageError.deserializationFailed + } + } + + // MARK: - Security + + private func applySecurity(_ data: Data, for key: any StorageKey, isEncrypt: Bool) throws -> Data { + switch key.security { + case .none: + return data + case .encrypted(let encryptionPolicy): + let derivedKey = try encryptionKey(for: key, policy: encryptionPolicy) + return isEncrypt ? try encrypt(data, using: derivedKey) : try decrypt(data, using: derivedKey) + case .keychain: + // Keychain security is handled in store/retrieve + return data + } + } + + private func encrypt(_ data: Data, using key: SymmetricKey) throws -> Data { + let sealedBox = try AES.GCM.seal(data, using: key) + guard let combined = sealedBox.combined else { + throw StorageError.securityApplicationFailed + } + return combined + } + + private func decrypt(_ data: Data, using key: SymmetricKey) throws -> Data { + let sealedBox = try AES.GCM.SealedBox(combined: data) + return try AES.GCM.open(sealedBox, using: key) + } + + private func encryptionKey(for key: any StorageKey, policy: SecurityPolicy.EncryptionPolicy) throws -> SymmetricKey { + switch policy { + case .aes256(let derivation): + let password = try masterKeyData() + let salt = derivation.salt ?? Data(key.name.utf8) + let derivedKeyData = try pbkdf2SHA256( + password: password, + salt: salt, + iterations: derivation.iterations, + keyLength: EncryptionConstants.masterKeyLength + ) + return SymmetricKey(data: derivedKeyData) + } + } + + private func masterKeyData() throws -> Data { + if let existing = try keychainGet( + service: EncryptionConstants.masterKeyService, + key: EncryptionConstants.masterKeyAccount + ) { + return existing + } + + var bytes = [UInt8](repeating: 0, count: EncryptionConstants.masterKeyLength) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + guard status == errSecSuccess else { + throw StorageError.securityApplicationFailed + } + + let data = Data(bytes) + try keychainSet( + data, + service: EncryptionConstants.masterKeyService, + key: EncryptionConstants.masterKeyAccount, + policy: .keychain(accessibility: .afterFirstUnlock, accessControl: nil) + ) + return data + } + + private func pbkdf2SHA256(password: Data, salt: Data, iterations: Int, keyLength: Int) throws -> Data { + guard iterations > 0 else { + throw StorageError.securityApplicationFailed + } + + var derivedKey = Data() + var blockIndex: UInt32 = 1 + + while derivedKey.count < keyLength { + var block = Data() + block.append(salt) + block.append(uint32Data(blockIndex)) + + var u = hmacSHA256(key: password, data: block) + var t = u + + if iterations > 1 { + for _ in 1.. Data { + let symmetricKey = SymmetricKey(data: key) + let mac = HMAC.authenticationCode(for: data, using: symmetricKey) + return Data(mac) + } + + private func xor(_ left: Data, _ right: Data) -> Data { + let xored = zip(left, right).map { $0 ^ $1 } + return Data(xored) + } + + private func uint32Data(_ value: UInt32) -> Data { + var bigEndian = value.bigEndian + return Data(bytes: &bigEndian, count: MemoryLayout.size) + } + + // MARK: - Storage + + private func store(_ data: Data, for key: any StorageKey) async throws { + switch key.domain { + case .userDefaults(let suite): + let defaults = try userDefaults(for: suite) + defaults.set(data, forKey: key.name) + case .keychain(let service): + try keychainSet(data, service: service, key: key.name, policy: key.security) + case .fileSystem(let directory): + let url = directory.url().appending(path: key.name) + try ensureDirectoryExists(at: url.deletingLastPathComponent()) + try writeData(data, to: url, options: [.atomic]) + case .encryptedFileSystem(let directory): + let url = directory.url().appending(path: key.name) + try ensureDirectoryExists(at: url.deletingLastPathComponent()) + try writeData(data, to: url, options: [.atomic, .completeFileProtection]) + } + } + + private func retrieve(for key: any StorageKey) async throws -> Data? { + switch key.domain { + case .userDefaults(let suite): + let defaults = try userDefaults(for: suite) + return defaults.data(forKey: key.name) + case .keychain(let service): + return try keychainGet(service: service, key: key.name) + case .fileSystem(let directory): + let url = directory.url().appending(path: key.name) + return try readData(from: url) + case .encryptedFileSystem(let directory): + let url = directory.url().appending(path: key.name) + return try readData(from: url) + } + } + + private func delete(for key: any StorageKey) async throws { + switch key.domain { + case .userDefaults(let suite): + let defaults = try userDefaults(for: suite) + defaults.removeObject(forKey: key.name) + case .keychain(let service): + try keychainDelete(service: service, key: key.name) + case .fileSystem(let directory), .encryptedFileSystem(let directory): + let url = directory.url().appending(path: key.name) + try deleteItemIfExists(at: url) + } + } + + private func userDefaults(for suite: String?) throws -> UserDefaults { + guard let suite else { return .standard } + guard let defaults = UserDefaults(suiteName: suite) else { + throw StorageError.invalidUserDefaultsSuite(suite) + } + return defaults + } + + private func ensureDirectoryExists(at url: URL) throws { + do { + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil) + } catch { + throw StorageError.fileError(error) + } + } + + private func writeData(_ data: Data, to url: URL, options: Data.WritingOptions) throws { + do { + try data.write(to: url, options: options) + } catch { + throw StorageError.fileError(error) + } + } + + private func readData(from url: URL) throws -> Data? { + guard FileManager.default.fileExists(atPath: url.path) else { + return nil + } + + do { + return try Data(contentsOf: url) + } catch { + throw StorageError.fileError(error) + } + } + + private func deleteItemIfExists(at url: URL) throws { + guard FileManager.default.fileExists(atPath: url.path) else { + return + } + + do { + try FileManager.default.removeItem(at: url) + } catch { + throw StorageError.fileError(error) + } + } + + // MARK: - Keychain Helpers + + private func keychainSet(_ data: Data, service: String, key: String, policy: SecurityPolicy) throws { + guard case let .keychain(accessibility, accessControl) = policy else { + throw StorageError.securityApplicationFailed + } + + var attributes: [String: Any] = [ + kSecValueData as String: data + ] + + if let accessControl { + guard let accessControlValue = accessControl.accessControl(accessibility: accessibility) else { + throw StorageError.securityApplicationFailed + } + attributes[kSecAttrAccessControl as String] = accessControlValue + } else { + attributes[kSecAttrAccessible as String] = accessibility.cfString + } + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + + let addQuery = query.merging(attributes) { _, new in new } + let status = SecItemAdd(addQuery as CFDictionary, nil) + if status == errSecDuplicateItem { + let updateStatus = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary) + if updateStatus != errSecSuccess { + throw StorageError.keychainError(updateStatus) + } + } else if status != errSecSuccess { + throw StorageError.keychainError(status) + } + } + + private func keychainGet(service: String, key: String) throws -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var item: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecSuccess { + return item as? Data + } else if status == errSecItemNotFound { + return nil + } else { + throw StorageError.keychainError(status) + } + } + + private func keychainDelete(service: String, key: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + + let status = SecItemDelete(query as CFDictionary) + if status != errSecSuccess && status != errSecItemNotFound { + throw StorageError.keychainError(status) + } + } + + // MARK: - Sync +#if os(iOS) || os(watchOS) + private func handleSync(_ key: any StorageKey, data: Data) async throws { + guard key.availability == .all || key.availability == .phoneWithWatchSync else { + return + } + + switch key.syncPolicy { + case .never: + return + case .automaticSmall: + if data.count > 100_000 { throw StorageError.dataTooLargeForSync } + fallthrough + case .manual: + guard WCSession.isSupported() else { return } + let session = WCSession.default + guard session.activationState == .activated else { return } + #if os(iOS) + guard session.isPaired, session.isWatchAppInstalled else { return } + #endif + try session.updateApplicationContext([key.name: data]) + } + } +#else + private func handleSync(_ key: any StorageKey, data: Data) async throws { + // No sync on other platforms + } +#endif +} diff --git a/Tests/LocalDataTests/LocalDataTests.swift b/Tests/LocalDataTests/LocalDataTests.swift new file mode 100644 index 0000000..70a04f6 --- /dev/null +++ b/Tests/LocalDataTests/LocalDataTests.swift @@ -0,0 +1,101 @@ +import Foundation +import XCTest +@testable import LocalData + +private struct TestUserDefaultsKey: StorageKey { + typealias Value = String + + let name: String + let domain: StorageDomain + let security: SecurityPolicy = .none + let serializer: Serializer = .json + let owner: String = "LocalDataTests" + let availability: PlatformAvailability = .all + let syncPolicy: SyncPolicy = .never + + init(name: String, suiteName: String) { + self.name = name + self.domain = .userDefaults(suite: suiteName) + } +} + +private struct TestFileKey: StorageKey { + typealias Value = String + + let name: String + let domain: StorageDomain + let security: SecurityPolicy = .none + let serializer: Serializer = .json + let owner: String = "LocalDataTests" + let availability: PlatformAvailability = .all + let syncPolicy: SyncPolicy = .never + + init(name: String, directory: URL) { + self.name = name + self.domain = .fileSystem(directory: .custom(directory)) + } +} + +final class LocalDataTests: XCTestCase { + private var suiteName: String = "" + private var tempDirectory: URL = .temporaryDirectory + + override func setUp() { + super.setUp() + suiteName = "LocalDataTests.\(UUID().uuidString)" + tempDirectory = FileManager.default.temporaryDirectory + .appending(path: "LocalDataTests") + .appending(path: UUID().uuidString) + } + + override func tearDown() { + if let defaults = UserDefaults(suiteName: suiteName) { + defaults.removePersistentDomain(forName: suiteName) + } + + try? FileManager.default.removeItem(at: tempDirectory) + super.tearDown() + } + + func testUserDefaultsRoundTrip() async throws { + let key = TestUserDefaultsKey(name: "test.string", suiteName: suiteName) + let storedValue = "1.0.0" + + try await StorageRouter.shared.set(storedValue, for: key) + let fetched = try await StorageRouter.shared.get(key) + + XCTAssertEqual(fetched, storedValue) + + try await StorageRouter.shared.remove(key) + + do { + _ = try await StorageRouter.shared.get(key) + XCTFail("Expected notFound error after removal") + } catch StorageError.notFound { + return + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testFileSystemRoundTrip() async throws { + let key = TestFileKey(name: "test.json", directory: tempDirectory) + let storedValue = "payload" + + try await StorageRouter.shared.set(storedValue, for: key) + let fetched = try await StorageRouter.shared.get(key) + + XCTAssertEqual(fetched, storedValue) + + try await StorageRouter.shared.remove(key) + + do { + _ = try await StorageRouter.shared.get(key) + XCTFail("Expected notFound error after removal") + } catch StorageError.notFound { + return + } catch { + XCTFail("Unexpected error: \(error)") + } + } +}