Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
c8631484ed
commit
86c3012bf7
@ -6,8 +6,13 @@ public struct FileStorageConfiguration: Sendable {
|
|||||||
/// 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?
|
||||||
|
|
||||||
public init(subDirectory: String? = nil) {
|
/// An optional base URL to override the default system directories.
|
||||||
|
/// Primarily used for testing isolation.
|
||||||
|
public let baseURL: URL?
|
||||||
|
|
||||||
|
public init(subDirectory: String? = nil, baseURL: URL? = nil) {
|
||||||
self.subDirectory = subDirectory
|
self.subDirectory = subDirectory
|
||||||
|
self.baseURL = baseURL
|
||||||
}
|
}
|
||||||
|
|
||||||
public static let `default` = FileStorageConfiguration()
|
public static let `default` = FileStorageConfiguration()
|
||||||
|
|||||||
@ -8,7 +8,7 @@ actor FileStorageHelper {
|
|||||||
|
|
||||||
private var configuration: FileStorageConfiguration
|
private var configuration: FileStorageConfiguration
|
||||||
|
|
||||||
private init(configuration: FileStorageConfiguration = .default) {
|
internal init(configuration: FileStorageConfiguration = .default) {
|
||||||
self.configuration = configuration
|
self.configuration = configuration
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -286,17 +286,23 @@ actor FileStorageHelper {
|
|||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveDirectoryURL(baseURL: URL? = nil, directory: FileDirectory) throws -> URL {
|
private func resolveDirectoryURL(baseURL overrideURL: URL? = nil, directory: FileDirectory) throws -> URL {
|
||||||
let base: URL
|
let base: URL
|
||||||
if let baseURL = baseURL {
|
// Priority: 1. Method override, 2. Configuration override, 3. System default
|
||||||
|
if let explicitBase = overrideURL ?? configuration.baseURL {
|
||||||
switch directory {
|
switch directory {
|
||||||
case .documents:
|
case .documents:
|
||||||
base = baseURL.appending(path: "Documents")
|
base = explicitBase.appending(path: "Documents")
|
||||||
case .caches:
|
case .caches:
|
||||||
base = baseURL.appending(path: "Library/Caches")
|
base = explicitBase.appending(path: "Library/Caches")
|
||||||
case .custom(let url):
|
case .custom(let url):
|
||||||
let relativePath = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
// If it's a custom URL, we treat it as relative to the base if it's not absolute or just use it.
|
||||||
return baseURL.appending(path: relativePath)
|
// But for isolation, if baseURL is set, we might want to nest it.
|
||||||
|
// For now, let's keep custom as is OR nest if it looks relative.
|
||||||
|
if url.isFileURL && url.path.hasPrefix("/") {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
return explicitBase.appending(path: url.path)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
base = directory.url()
|
base = directory.url()
|
||||||
|
|||||||
@ -9,7 +9,7 @@ actor SyncHelper {
|
|||||||
|
|
||||||
private var configuration: SyncConfiguration
|
private var configuration: SyncConfiguration
|
||||||
|
|
||||||
private init(configuration: SyncConfiguration = .default) {
|
internal init(configuration: SyncConfiguration = .default) {
|
||||||
self.configuration = configuration
|
self.configuration = configuration
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -111,7 +111,7 @@ 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.
|
/// This is called by the delegate proxy.
|
||||||
fileprivate 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 {
|
||||||
guard let data = value as? Data else {
|
guard let data = value as? Data else {
|
||||||
|
|||||||
@ -6,7 +6,11 @@ actor UserDefaultsHelper {
|
|||||||
|
|
||||||
public static let shared = UserDefaultsHelper()
|
public static let shared = UserDefaultsHelper()
|
||||||
|
|
||||||
private init() {}
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
|
internal init(defaults: UserDefaults = .standard) {
|
||||||
|
self.defaults = defaults
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Public Interface
|
// MARK: - Public Interface
|
||||||
|
|
||||||
@ -117,14 +121,12 @@ actor UserDefaultsHelper {
|
|||||||
// MARK: - Private Helpers
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
private func userDefaults(for suite: String?) throws -> UserDefaults {
|
private func userDefaults(for suite: String?) throws -> UserDefaults {
|
||||||
guard let suite else {
|
if let suite {
|
||||||
return .standard
|
guard let suiteDefaults = UserDefaults(suiteName: suite) else {
|
||||||
}
|
|
||||||
|
|
||||||
guard let defaults = UserDefaults(suiteName: suite) else {
|
|
||||||
throw StorageError.invalidUserDefaultsSuite(suite)
|
throw StorageError.invalidUserDefaultsSuite(suite)
|
||||||
}
|
}
|
||||||
|
return suiteDefaults
|
||||||
|
}
|
||||||
return defaults
|
return defaults
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import Security
|
|||||||
|
|
||||||
/// Defines additional access control requirements for keychain items.
|
/// Defines additional access control requirements for keychain items.
|
||||||
/// These flags can require user authentication before accessing the item.
|
/// These flags can require user authentication before accessing the item.
|
||||||
public enum KeychainAccessControl: Sendable, CaseIterable {
|
public enum KeychainAccessControl: Equatable, Sendable, CaseIterable {
|
||||||
/// Requires any form of user presence (biometric or passcode).
|
/// Requires any form of user presence (biometric or passcode).
|
||||||
case userPresence
|
case userPresence
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import Security
|
|||||||
|
|
||||||
/// Defines when a keychain item can be accessed.
|
/// Defines when a keychain item can be accessed.
|
||||||
/// Maps directly to Security framework's kSecAttrAccessible constants.
|
/// Maps directly to Security framework's kSecAttrAccessible constants.
|
||||||
public enum KeychainAccessibility: Sendable, CaseIterable {
|
public enum KeychainAccessibility: Equatable, Sendable, CaseIterable {
|
||||||
/// Item is only accessible while the device is unlocked.
|
/// Item is only accessible while the device is unlocked.
|
||||||
/// This is the most restrictive option for general use.
|
/// This is the most restrictive option for general use.
|
||||||
case whenUnlocked
|
case whenUnlocked
|
||||||
|
|||||||
@ -2,14 +2,14 @@ import Foundation
|
|||||||
import CryptoKit
|
import CryptoKit
|
||||||
import Security
|
import Security
|
||||||
|
|
||||||
public enum SecurityPolicy: Sendable {
|
public enum SecurityPolicy: Equatable, Sendable {
|
||||||
case none
|
case none
|
||||||
case encrypted(EncryptionPolicy)
|
case encrypted(EncryptionPolicy)
|
||||||
case keychain(accessibility: KeychainAccessibility, accessControl: KeychainAccessControl?)
|
case keychain(accessibility: KeychainAccessibility, accessControl: KeychainAccessControl?)
|
||||||
|
|
||||||
public static let recommended: SecurityPolicy = .encrypted(.recommended)
|
public static let recommended: SecurityPolicy = .encrypted(.recommended)
|
||||||
|
|
||||||
public enum EncryptionPolicy: Sendable {
|
public enum EncryptionPolicy: Equatable, Sendable {
|
||||||
case aes256(keyDerivation: KeyDerivation)
|
case aes256(keyDerivation: KeyDerivation)
|
||||||
case chacha20Poly1305(keyDerivation: KeyDerivation)
|
case chacha20Poly1305(keyDerivation: KeyDerivation)
|
||||||
case external(source: KeyMaterialSource, keyDerivation: KeyDerivation)
|
case external(source: KeyMaterialSource, keyDerivation: KeyDerivation)
|
||||||
@ -20,7 +20,7 @@ public enum SecurityPolicy: Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum KeyDerivation: Sendable {
|
public enum KeyDerivation: Equatable, Sendable {
|
||||||
case pbkdf2(iterations: Int? = nil, salt: Data? = nil)
|
case pbkdf2(iterations: Int? = nil, salt: Data? = nil)
|
||||||
case hkdf(salt: Data? = nil, info: Data? = nil)
|
case hkdf(salt: Data? = nil, info: Data? = nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,12 +12,26 @@ public actor StorageRouter: StorageProviding {
|
|||||||
private var registeredEntries: [AnyStorageKey] = []
|
private var registeredEntries: [AnyStorageKey] = []
|
||||||
private var storageConfiguration: StorageConfiguration = .default
|
private var storageConfiguration: StorageConfiguration = .default
|
||||||
private let keychain: KeychainStoring
|
private let keychain: KeychainStoring
|
||||||
|
private let encryption: EncryptionHelper
|
||||||
|
private let file: FileStorageHelper
|
||||||
|
private let defaults: UserDefaultsHelper
|
||||||
|
private let sync: SyncHelper
|
||||||
|
|
||||||
/// Initialize a new StorageRouter.
|
/// Initialize a new StorageRouter.
|
||||||
/// Internal for testing isolation via @testable import.
|
/// Internal for testing isolation via @testable import.
|
||||||
/// Consumers should use the `shared` singleton.
|
/// Consumers should use the `shared` singleton.
|
||||||
internal init(keychain: KeychainStoring = KeychainHelper.shared) {
|
internal init(
|
||||||
|
keychain: KeychainStoring = KeychainHelper.shared,
|
||||||
|
encryption: EncryptionHelper = .shared,
|
||||||
|
file: FileStorageHelper = .shared,
|
||||||
|
defaults: UserDefaultsHelper = .shared,
|
||||||
|
sync: SyncHelper = .shared
|
||||||
|
) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
|
self.encryption = encryption
|
||||||
|
self.file = file
|
||||||
|
self.defaults = defaults
|
||||||
|
self.sync = sync
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Configuration
|
// MARK: - Configuration
|
||||||
@ -27,18 +41,18 @@ public actor StorageRouter: StorageProviding {
|
|||||||
/// > Changing these constants in an existing app will cause the app to look for the master key
|
/// > Changing these constants in an existing app will cause the app to look for the master key
|
||||||
/// > under a new name. Previously encrypted data will be lost.
|
/// > under a new name. Previously encrypted data will be lost.
|
||||||
public func updateEncryptionConfiguration(_ configuration: EncryptionConfiguration) async {
|
public func updateEncryptionConfiguration(_ configuration: EncryptionConfiguration) async {
|
||||||
await EncryptionHelper.shared.updateConfiguration(configuration)
|
await encryption.updateConfiguration(configuration)
|
||||||
await EncryptionHelper.shared.updateKeychainHelper(keychain)
|
await encryption.updateKeychainHelper(keychain)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates the sync configuration.
|
/// Updates the sync configuration.
|
||||||
public func updateSyncConfiguration(_ configuration: SyncConfiguration) async {
|
public func updateSyncConfiguration(_ configuration: SyncConfiguration) async {
|
||||||
await SyncHelper.shared.updateConfiguration(configuration)
|
await sync.updateConfiguration(configuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates the file storage configuration.
|
/// Updates the file storage configuration.
|
||||||
public func updateFileStorageConfiguration(_ configuration: FileStorageConfiguration) async {
|
public func updateFileStorageConfiguration(_ configuration: FileStorageConfiguration) async {
|
||||||
await FileStorageHelper.shared.updateConfiguration(configuration)
|
await file.updateConfiguration(configuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates the global storage configuration (defaults).
|
/// Updates the global storage configuration (defaults).
|
||||||
@ -53,8 +67,8 @@ public actor StorageRouter: StorageProviding {
|
|||||||
_ provider: any KeyMaterialProviding,
|
_ provider: any KeyMaterialProviding,
|
||||||
for source: KeyMaterialSource
|
for source: KeyMaterialSource
|
||||||
) async {
|
) async {
|
||||||
await EncryptionHelper.shared.updateKeychainHelper(keychain)
|
await encryption.updateKeychainHelper(keychain)
|
||||||
await EncryptionHelper.shared.registerKeyMaterialProvider(provider, for: source)
|
await encryption.registerKeyMaterialProvider(provider, for: source)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registers a catalog of known storage keys for audit and validation.
|
/// Registers a catalog of known storage keys for audit and validation.
|
||||||
@ -194,18 +208,18 @@ public actor StorageRouter: StorageProviding {
|
|||||||
|
|
||||||
switch key.domain {
|
switch key.domain {
|
||||||
case .userDefaults(let suite):
|
case .userDefaults(let suite):
|
||||||
return try await UserDefaultsHelper.shared.exists(forKey: key.name, suite: suite)
|
return try await defaults.exists(forKey: key.name, suite: suite)
|
||||||
case .appGroupUserDefaults(let identifier):
|
case .appGroupUserDefaults(let identifier):
|
||||||
let resolvedId = try resolveIdentifier(identifier)
|
let resolvedId = try resolveIdentifier(identifier)
|
||||||
return try await UserDefaultsHelper.shared.exists(forKey: key.name, appGroupIdentifier: resolvedId)
|
return try await defaults.exists(forKey: key.name, appGroupIdentifier: resolvedId)
|
||||||
case .keychain(let service):
|
case .keychain(let service):
|
||||||
let resolvedService = try resolveService(service)
|
let resolvedService = try resolveService(service)
|
||||||
return try await keychain.exists(service: resolvedService, key: key.name)
|
return try await keychain.exists(service: resolvedService, key: key.name)
|
||||||
case .fileSystem(let directory), .encryptedFileSystem(let directory):
|
case .fileSystem(let directory), .encryptedFileSystem(let directory):
|
||||||
return await FileStorageHelper.shared.exists(in: directory, fileName: key.name)
|
return await file.exists(in: directory, fileName: key.name)
|
||||||
case .appGroupFileSystem(let identifier, let directory):
|
case .appGroupFileSystem(let identifier, let directory):
|
||||||
let resolvedId = try resolveIdentifier(identifier)
|
let resolvedId = try resolveIdentifier(identifier)
|
||||||
return await FileStorageHelper.shared.exists(
|
return await file.exists(
|
||||||
in: directory,
|
in: directory,
|
||||||
fileName: key.name,
|
fileName: key.name,
|
||||||
appGroupIdentifier: resolvedId
|
appGroupIdentifier: resolvedId
|
||||||
@ -313,15 +327,15 @@ public actor StorageRouter: StorageProviding {
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
case .encrypted(let encryptionPolicy):
|
case .encrypted(let encryptionPolicy):
|
||||||
await EncryptionHelper.shared.updateKeychainHelper(keychain)
|
await encryption.updateKeychainHelper(keychain)
|
||||||
if isEncrypt {
|
if isEncrypt {
|
||||||
return try await EncryptionHelper.shared.encrypt(
|
return try await encryption.encrypt(
|
||||||
data,
|
data,
|
||||||
keyName: descriptor.name,
|
keyName: descriptor.name,
|
||||||
policy: encryptionPolicy
|
policy: encryptionPolicy
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
return try await EncryptionHelper.shared.decrypt(
|
return try await encryption.decrypt(
|
||||||
data,
|
data,
|
||||||
keyName: descriptor.name,
|
keyName: descriptor.name,
|
||||||
policy: encryptionPolicy
|
policy: encryptionPolicy
|
||||||
@ -343,11 +357,11 @@ public actor StorageRouter: StorageProviding {
|
|||||||
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):
|
||||||
try await UserDefaultsHelper.shared.set(data, forKey: descriptor.name, suite: suite)
|
try await defaults.set(data, forKey: descriptor.name, suite: suite)
|
||||||
|
|
||||||
case .appGroupUserDefaults(let identifier):
|
case .appGroupUserDefaults(let identifier):
|
||||||
let resolvedId = try resolveIdentifier(identifier)
|
let resolvedId = try resolveIdentifier(identifier)
|
||||||
try await UserDefaultsHelper.shared.set(data, forKey: descriptor.name, appGroupIdentifier: resolvedId)
|
try await defaults.set(data, forKey: descriptor.name, appGroupIdentifier: resolvedId)
|
||||||
|
|
||||||
case .keychain(let service):
|
case .keychain(let service):
|
||||||
guard case let .keychain(accessibility, accessControl) = descriptor.security else {
|
guard case let .keychain(accessibility, accessControl) = descriptor.security else {
|
||||||
@ -363,7 +377,7 @@ public actor StorageRouter: StorageProviding {
|
|||||||
)
|
)
|
||||||
|
|
||||||
case .fileSystem(let directory):
|
case .fileSystem(let directory):
|
||||||
try await FileStorageHelper.shared.write(
|
try await file.write(
|
||||||
data,
|
data,
|
||||||
to: directory,
|
to: directory,
|
||||||
fileName: descriptor.name,
|
fileName: descriptor.name,
|
||||||
@ -371,7 +385,7 @@ public actor StorageRouter: StorageProviding {
|
|||||||
)
|
)
|
||||||
|
|
||||||
case .encryptedFileSystem(let directory):
|
case .encryptedFileSystem(let directory):
|
||||||
try await FileStorageHelper.shared.write(
|
try await file.write(
|
||||||
data,
|
data,
|
||||||
to: directory,
|
to: directory,
|
||||||
fileName: descriptor.name,
|
fileName: descriptor.name,
|
||||||
@ -380,7 +394,7 @@ public actor StorageRouter: StorageProviding {
|
|||||||
|
|
||||||
case .appGroupFileSystem(let identifier, let directory):
|
case .appGroupFileSystem(let identifier, let directory):
|
||||||
let resolvedId = try resolveIdentifier(identifier)
|
let resolvedId = try resolveIdentifier(identifier)
|
||||||
try await FileStorageHelper.shared.write(
|
try await file.write(
|
||||||
data,
|
data,
|
||||||
to: directory,
|
to: directory,
|
||||||
fileName: descriptor.name,
|
fileName: descriptor.name,
|
||||||
@ -393,20 +407,20 @@ public actor StorageRouter: StorageProviding {
|
|||||||
private func retrieve(for descriptor: StorageKeyDescriptor) async throws -> Data? {
|
private func retrieve(for descriptor: StorageKeyDescriptor) async throws -> Data? {
|
||||||
switch descriptor.domain {
|
switch descriptor.domain {
|
||||||
case .userDefaults(let suite):
|
case .userDefaults(let suite):
|
||||||
return try await UserDefaultsHelper.shared.get(forKey: descriptor.name, suite: suite)
|
return try await defaults.get(forKey: descriptor.name, suite: suite)
|
||||||
case .appGroupUserDefaults(let identifier):
|
case .appGroupUserDefaults(let identifier):
|
||||||
let resolvedId = try resolveIdentifier(identifier)
|
let resolvedId = try resolveIdentifier(identifier)
|
||||||
return try await UserDefaultsHelper.shared.get(forKey: descriptor.name, appGroupIdentifier: resolvedId)
|
return try await defaults.get(forKey: descriptor.name, appGroupIdentifier: resolvedId)
|
||||||
|
|
||||||
case .keychain(let service):
|
case .keychain(let service):
|
||||||
let resolvedService = try resolveService(service)
|
let resolvedService = try resolveService(service)
|
||||||
return try await keychain.get(service: resolvedService, key: descriptor.name)
|
return try await keychain.get(service: resolvedService, key: descriptor.name)
|
||||||
|
|
||||||
case .fileSystem(let directory), .encryptedFileSystem(let directory):
|
case .fileSystem(let directory), .encryptedFileSystem(let directory):
|
||||||
return try await FileStorageHelper.shared.read(from: directory, fileName: descriptor.name)
|
return try await file.read(from: directory, fileName: descriptor.name)
|
||||||
case .appGroupFileSystem(let identifier, let directory):
|
case .appGroupFileSystem(let identifier, let directory):
|
||||||
let resolvedId = try resolveIdentifier(identifier)
|
let resolvedId = try resolveIdentifier(identifier)
|
||||||
return try await FileStorageHelper.shared.read(
|
return try await file.read(
|
||||||
from: directory,
|
from: directory,
|
||||||
fileName: descriptor.name,
|
fileName: descriptor.name,
|
||||||
appGroupIdentifier: resolvedId
|
appGroupIdentifier: resolvedId
|
||||||
@ -417,20 +431,20 @@ public actor StorageRouter: StorageProviding {
|
|||||||
private func delete(for descriptor: StorageKeyDescriptor) async throws {
|
private func delete(for descriptor: StorageKeyDescriptor) async throws {
|
||||||
switch descriptor.domain {
|
switch descriptor.domain {
|
||||||
case .userDefaults(let suite):
|
case .userDefaults(let suite):
|
||||||
try await UserDefaultsHelper.shared.remove(forKey: descriptor.name, suite: suite)
|
try await defaults.remove(forKey: descriptor.name, suite: suite)
|
||||||
case .appGroupUserDefaults(let identifier):
|
case .appGroupUserDefaults(let identifier):
|
||||||
let resolvedId = try resolveIdentifier(identifier)
|
let resolvedId = try resolveIdentifier(identifier)
|
||||||
try await UserDefaultsHelper.shared.remove(forKey: descriptor.name, appGroupIdentifier: resolvedId)
|
try await defaults.remove(forKey: descriptor.name, appGroupIdentifier: resolvedId)
|
||||||
|
|
||||||
case .keychain(let service):
|
case .keychain(let service):
|
||||||
let resolvedService = try resolveService(service)
|
let resolvedService = try resolveService(service)
|
||||||
try await keychain.delete(service: resolvedService, key: descriptor.name)
|
try await keychain.delete(service: resolvedService, key: descriptor.name)
|
||||||
|
|
||||||
case .fileSystem(let directory), .encryptedFileSystem(let directory):
|
case .fileSystem(let directory), .encryptedFileSystem(let directory):
|
||||||
try await FileStorageHelper.shared.delete(from: directory, fileName: descriptor.name)
|
try await file.delete(from: directory, fileName: descriptor.name)
|
||||||
case .appGroupFileSystem(let identifier, let directory):
|
case .appGroupFileSystem(let identifier, let directory):
|
||||||
let resolvedId = try resolveIdentifier(identifier)
|
let resolvedId = try resolveIdentifier(identifier)
|
||||||
try await FileStorageHelper.shared.delete(
|
try await file.delete(
|
||||||
from: directory,
|
from: directory,
|
||||||
fileName: descriptor.name,
|
fileName: descriptor.name,
|
||||||
appGroupIdentifier: resolvedId
|
appGroupIdentifier: resolvedId
|
||||||
@ -441,7 +455,7 @@ public actor StorageRouter: StorageProviding {
|
|||||||
// MARK: - Sync
|
// MARK: - Sync
|
||||||
|
|
||||||
private func handleSync(_ key: any StorageKey, data: Data) async throws {
|
private func handleSync(_ key: any StorageKey, data: Data) async throws {
|
||||||
try await SyncHelper.shared.syncIfNeeded(
|
try await sync.syncIfNeeded(
|
||||||
data: data,
|
data: data,
|
||||||
keyName: key.name,
|
keyName: key.name,
|
||||||
availability: key.availability,
|
availability: key.availability,
|
||||||
|
|||||||
56
Tests/LocalDataTests/AppGroupTests.swift
Normal file
56
Tests/LocalDataTests/AppGroupTests.swift
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import LocalData
|
||||||
|
|
||||||
|
@Suite struct AppGroupTests {
|
||||||
|
private let userDefaultsHelper: UserDefaultsHelper
|
||||||
|
private let fileStorageHelper: FileStorageHelper
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let suiteName = "AppGroupTests-\(UUID().uuidString)"
|
||||||
|
userDefaultsHelper = UserDefaultsHelper(defaults: UserDefaults(suiteName: suiteName)!)
|
||||||
|
|
||||||
|
let testBaseURL = FileManager.default.temporaryDirectory.appending(path: "AppGroupTests-\(UUID().uuidString)")
|
||||||
|
fileStorageHelper = FileStorageHelper(configuration: FileStorageConfiguration(baseURL: testBaseURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: These tests might fail to find real containers in a unit test environment,
|
||||||
|
// but they will exercise the routing logic.
|
||||||
|
|
||||||
|
@Test func userDefaultsAppGroupRoundTrip() async throws {
|
||||||
|
let identifier = "group.com.test.localdata"
|
||||||
|
let key = "test.appgroup.key"
|
||||||
|
let data = Data("appgroup-value".utf8)
|
||||||
|
|
||||||
|
// This usually works in simulators even without real entitlements
|
||||||
|
try await userDefaultsHelper.set(data, forKey: key, appGroupIdentifier: identifier)
|
||||||
|
let retrieved = try await userDefaultsHelper.get(forKey: key, appGroupIdentifier: identifier)
|
||||||
|
#expect(retrieved == data)
|
||||||
|
|
||||||
|
let exists = try await userDefaultsHelper.exists(forKey: key, appGroupIdentifier: identifier)
|
||||||
|
#expect(exists == true)
|
||||||
|
|
||||||
|
let allKeys = try await userDefaultsHelper.allKeys(appGroupIdentifier: identifier)
|
||||||
|
#expect(allKeys.contains(key))
|
||||||
|
|
||||||
|
try await userDefaultsHelper.remove(forKey: key, appGroupIdentifier: identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func fileStorageAppGroupError() async throws {
|
||||||
|
let identifier = "invalid.group.id"
|
||||||
|
let data = Data("data".utf8)
|
||||||
|
|
||||||
|
// Simulators often return a URL even for invalid group IDs.
|
||||||
|
// On real devices/authorized environments, it returns nil if not matched.
|
||||||
|
if FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: identifier) == nil {
|
||||||
|
await #expect(throws: StorageError.invalidAppGroupIdentifier(identifier)) {
|
||||||
|
try await fileStorageHelper.write(
|
||||||
|
data,
|
||||||
|
to: .documents,
|
||||||
|
fileName: "test.txt",
|
||||||
|
appGroupIdentifier: identifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
Tests/LocalDataTests/FileStorageHelperExpansionTests.swift
Normal file
52
Tests/LocalDataTests/FileStorageHelperExpansionTests.swift
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import LocalData
|
||||||
|
|
||||||
|
@Suite struct FileStorageHelperExpansionTests {
|
||||||
|
private let helper: FileStorageHelper
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let testBaseURL = FileManager.default.temporaryDirectory.appending(path: "FileStorageExpansionTests-\(UUID().uuidString)")
|
||||||
|
helper = FileStorageHelper(configuration: FileStorageConfiguration(baseURL: testBaseURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func subDirectoryLogic() async throws {
|
||||||
|
// 1. Update config with sub-directory
|
||||||
|
let subDir = "test-subdir"
|
||||||
|
await helper.updateConfiguration(FileStorageConfiguration(subDirectory: subDir))
|
||||||
|
|
||||||
|
defer {
|
||||||
|
Task {
|
||||||
|
await helper.updateConfiguration(.default)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileName = "subdir-file.txt"
|
||||||
|
let data = Data("subdir content".utf8)
|
||||||
|
|
||||||
|
try await helper.write(data, to: .caches, fileName: fileName)
|
||||||
|
|
||||||
|
// 2. Verify it exists
|
||||||
|
let exists = await helper.exists(in: .caches, fileName: fileName)
|
||||||
|
#expect(exists == true)
|
||||||
|
|
||||||
|
// 3. Verify it's actually in a sub-directory (internal check via list)
|
||||||
|
// This is a bit hard with the actor if we don't have the path,
|
||||||
|
// but it exercises the code.
|
||||||
|
|
||||||
|
try await helper.delete(from: .caches, fileName: fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func fileProtectionLogic() async throws {
|
||||||
|
let fileName = "protected.txt"
|
||||||
|
let data = Data("secret".utf8)
|
||||||
|
|
||||||
|
// This exercises the 'useCompleteFileProtection' branch
|
||||||
|
try await helper.write(data, to: .documents, fileName: fileName, useCompleteFileProtection: true)
|
||||||
|
|
||||||
|
let retrieved = try await helper.read(from: .documents, fileName: fileName)
|
||||||
|
#expect(retrieved == data)
|
||||||
|
|
||||||
|
try await helper.delete(from: .documents, fileName: fileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,7 +3,13 @@ import Testing
|
|||||||
@testable import LocalData
|
@testable import LocalData
|
||||||
|
|
||||||
@Suite struct FileStorageHelperTests {
|
@Suite struct FileStorageHelperTests {
|
||||||
private let helper = FileStorageHelper.shared
|
private let helper: FileStorageHelper
|
||||||
|
private let testBaseURL: URL
|
||||||
|
|
||||||
|
init() {
|
||||||
|
testBaseURL = FileManager.default.temporaryDirectory.appending(path: "LocalDataTests-\(UUID().uuidString)")
|
||||||
|
helper = FileStorageHelper(configuration: FileStorageConfiguration(baseURL: testBaseURL))
|
||||||
|
}
|
||||||
|
|
||||||
@Test func documentsDirectoryRoundTrip() async throws {
|
@Test func documentsDirectoryRoundTrip() async throws {
|
||||||
let fileName = "test_file_\(UUID().uuidString).data"
|
let fileName = "test_file_\(UUID().uuidString).data"
|
||||||
|
|||||||
@ -35,7 +35,17 @@ private struct ModernKey: StorageKey {
|
|||||||
|
|
||||||
@Suite(.serialized)
|
@Suite(.serialized)
|
||||||
struct MigrationTests {
|
struct MigrationTests {
|
||||||
private let router = StorageRouter(keychain: MockKeychainHelper())
|
private let router: StorageRouter
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let testBaseURL = FileManager.default.temporaryDirectory.appending(path: "MigrationTests-\(UUID().uuidString)")
|
||||||
|
router = StorageRouter(
|
||||||
|
keychain: MockKeychainHelper(),
|
||||||
|
encryption: EncryptionHelper(keychain: MockKeychainHelper()),
|
||||||
|
file: FileStorageHelper(configuration: FileStorageConfiguration(baseURL: testBaseURL)),
|
||||||
|
defaults: UserDefaultsHelper(defaults: UserDefaults(suiteName: "MigrationTests-\(UUID().uuidString)")!)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test func automaticMigrationFromUserDefaultsToKeychain() async throws {
|
@Test func automaticMigrationFromUserDefaultsToKeychain() async throws {
|
||||||
let legacyName = "legacy.user.name"
|
let legacyName = "legacy.user.name"
|
||||||
|
|||||||
23
Tests/LocalDataTests/RouterConfigurationTests.swift
Normal file
23
Tests/LocalDataTests/RouterConfigurationTests.swift
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import LocalData
|
||||||
|
|
||||||
|
@Suite struct RouterConfigurationTests {
|
||||||
|
private let router = StorageRouter(keychain: MockKeychainHelper())
|
||||||
|
|
||||||
|
@Test func updateConfigurations() async {
|
||||||
|
// Exercise the configuration update paths in StorageRouter
|
||||||
|
await router.updateStorageConfiguration(.default)
|
||||||
|
await router.updateEncryptionConfiguration(.default)
|
||||||
|
await router.updateSyncConfiguration(.default)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func registerKeyMaterialProvider() async throws {
|
||||||
|
struct Provider: KeyMaterialProviding {
|
||||||
|
func keyMaterial(for keyName: String) async throws -> Data { Data() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exercise registration path
|
||||||
|
await router.registerKeyMaterialProvider(Provider(), for: KeyMaterialSource(id: "test.source"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -22,7 +22,17 @@ private struct PartialCatalog: StorageKeyCatalog {
|
|||||||
|
|
||||||
@Suite(.serialized)
|
@Suite(.serialized)
|
||||||
struct RouterErrorTests {
|
struct RouterErrorTests {
|
||||||
private let router = StorageRouter(keychain: MockKeychainHelper())
|
private let router: StorageRouter
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let testBaseURL = FileManager.default.temporaryDirectory.appending(path: "RouterErrorTests-\(UUID().uuidString)")
|
||||||
|
router = StorageRouter(
|
||||||
|
keychain: MockKeychainHelper(),
|
||||||
|
encryption: EncryptionHelper(keychain: MockKeychainHelper()),
|
||||||
|
file: FileStorageHelper(configuration: FileStorageConfiguration(baseURL: testBaseURL)),
|
||||||
|
defaults: UserDefaultsHelper(defaults: UserDefaults(suiteName: "RouterErrorTests-\(UUID().uuidString)")!)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test func unregisteredKeyThrows() async throws {
|
@Test func unregisteredKeyThrows() async throws {
|
||||||
try await router.registerCatalog(PartialCatalog.self)
|
try await router.registerCatalog(PartialCatalog.self)
|
||||||
|
|||||||
23
Tests/LocalDataTests/StorageKeyDefaultsTests.swift
Normal file
23
Tests/LocalDataTests/StorageKeyDefaultsTests.swift
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import LocalData
|
||||||
|
|
||||||
|
@Suite struct StorageKeyDefaultsTests {
|
||||||
|
|
||||||
|
private struct MinimalKey: StorageKey {
|
||||||
|
typealias Value = Int
|
||||||
|
let name: String = "minimal.key"
|
||||||
|
let domain: StorageDomain = .userDefaults(suite: nil)
|
||||||
|
let serializer: Serializer<Int> = .json
|
||||||
|
let owner: String = "Test"
|
||||||
|
let description: String = "Test"
|
||||||
|
let availability: PlatformAvailability = .all
|
||||||
|
let syncPolicy: SyncPolicy = .never
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func defaultSecurityPolicyIsRecommended() {
|
||||||
|
let key = MinimalKey()
|
||||||
|
// This exercises the default implementation in StorageKey+Defaults.swift
|
||||||
|
#expect(key.security == .recommended)
|
||||||
|
}
|
||||||
|
}
|
||||||
31
Tests/LocalDataTests/SyncHelperExpansionTests.swift
Normal file
31
Tests/LocalDataTests/SyncHelperExpansionTests.swift
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import LocalData
|
||||||
|
|
||||||
|
@Suite struct SyncHelperExpansionTests {
|
||||||
|
private let helper = SyncHelper()
|
||||||
|
|
||||||
|
@Test func handleReceivedContextProcessing() async throws {
|
||||||
|
let key = "received.key"
|
||||||
|
let value = Data("received.data".utf8)
|
||||||
|
let context: [String: Any] = [key: value, "invalid": "not-data"]
|
||||||
|
|
||||||
|
// This exercises the loop and the data casting
|
||||||
|
await helper.handleReceivedContext(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func manualSyncExercisesPerformSync() async throws {
|
||||||
|
let data = Data("manual-sync-data".utf8)
|
||||||
|
|
||||||
|
// This will likely return early in most test environments due to WCSession state,
|
||||||
|
// but it exercises the public entry point.
|
||||||
|
try await helper.manualSync(data: data, keyName: "manual.key")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func currentContextReturnsEmptyIfNotSupported() async throws {
|
||||||
|
// This exercises the guard in currentContext()
|
||||||
|
let context = await helper.currentContext()
|
||||||
|
// WCSession might or might not be supported, but this hits the line
|
||||||
|
let _ = context.count
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,7 +3,7 @@ import Testing
|
|||||||
@testable import LocalData
|
@testable import LocalData
|
||||||
|
|
||||||
@Suite struct SyncHelperTests {
|
@Suite struct SyncHelperTests {
|
||||||
private let helper = SyncHelper.shared
|
private let helper = SyncHelper()
|
||||||
|
|
||||||
@Test func syncPolicyNeverDoesNothing() async throws {
|
@Test func syncPolicyNeverDoesNothing() async throws {
|
||||||
// This should return early without doing anything
|
// This should return early without doing anything
|
||||||
@ -17,12 +17,12 @@ import Testing
|
|||||||
|
|
||||||
@Test func syncPolicyAutomaticSmallThrowsIfTooLarge() async throws {
|
@Test func syncPolicyAutomaticSmallThrowsIfTooLarge() async throws {
|
||||||
let config = SyncConfiguration(maxAutoSyncSize: 10)
|
let config = SyncConfiguration(maxAutoSyncSize: 10)
|
||||||
await helper.updateConfiguration(config)
|
let localHelper = SyncHelper(configuration: config)
|
||||||
|
|
||||||
let largeData = Data(repeating: 0, count: 100)
|
let largeData = Data(repeating: 0, count: 100)
|
||||||
|
|
||||||
await #expect(throws: StorageError.dataTooLargeForSync) {
|
await #expect(throws: StorageError.dataTooLargeForSync) {
|
||||||
try await helper.syncIfNeeded(
|
try await localHelper.syncIfNeeded(
|
||||||
data: largeData,
|
data: largeData,
|
||||||
keyName: "too.large",
|
keyName: "too.large",
|
||||||
availability: .all,
|
availability: .all,
|
||||||
|
|||||||
@ -4,7 +4,17 @@ import Testing
|
|||||||
|
|
||||||
@Suite(.serialized)
|
@Suite(.serialized)
|
||||||
struct SyncIntegrationTests {
|
struct SyncIntegrationTests {
|
||||||
private let router = StorageRouter(keychain: MockKeychainHelper())
|
private let router: StorageRouter
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let testBaseURL = FileManager.default.temporaryDirectory.appending(path: "SyncIntegrationTests-\(UUID().uuidString)")
|
||||||
|
router = StorageRouter(
|
||||||
|
keychain: MockKeychainHelper(),
|
||||||
|
encryption: EncryptionHelper(keychain: MockKeychainHelper()),
|
||||||
|
file: FileStorageHelper(configuration: FileStorageConfiguration(baseURL: testBaseURL)),
|
||||||
|
defaults: UserDefaultsHelper(defaults: UserDefaults(suiteName: "SyncIntegrationTests-\(UUID().uuidString)")!)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private struct SyncKey: StorageKey {
|
private struct SyncKey: StorageKey {
|
||||||
typealias Value = String
|
typealias Value = String
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user