diff --git a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift index 6e9eacd9..a63e5726 100644 --- a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift @@ -150,23 +150,36 @@ package func setValue( _ newValue: some CKRecordValueProtocol & Equatable, forKey key: CKRecord.FieldKey, - at userModificationTime: Int64 + at userModificationTime: Int64, + encrypted: Bool = true ) -> Bool { - guard - encryptedValues[at: key] <= userModificationTime, - encryptedValues[key] != newValue - else { return false } - encryptedValues[key] = newValue - encryptedValues[at: key] = userModificationTime - self.userModificationTime = userModificationTime - return true + if encrypted { + guard + encryptedValues[at: key] <= userModificationTime, + encryptedValues[key] != newValue + else { return false } + encryptedValues[key] = newValue + encryptedValues[at: key] = userModificationTime + self.userModificationTime = userModificationTime + return true + } else { + guard + encryptedValues[at: key] <= userModificationTime, + self[key] as? (any Equatable) as? AnyHashable != newValue as? AnyHashable + else { return false } + self[key] = newValue + encryptedValues[at: key] = userModificationTime + self.userModificationTime = userModificationTime + return true + } } @discardableResult package func setAsset( _ newValue: CKAsset, forKey key: CKRecord.FieldKey, - at userModificationTime: Int64 + at userModificationTime: Int64, + encrypted: Bool = true ) -> Bool { @Dependency(\.dataManager) var dataManager guard @@ -189,7 +202,8 @@ package func setValue( _ newValue: [UInt8], forKey key: CKRecord.FieldKey, - at userModificationTime: Int64 + at userModificationTime: Int64, + encrypted: Bool = true ) -> Bool { guard encryptedValues[at: key] <= userModificationTime else { return false } @@ -217,54 +231,65 @@ @discardableResult package func removeValue( forKey key: CKRecord.FieldKey, - at userModificationTime: Int64 + at userModificationTime: Int64, + encrypted: Bool = true ) -> Bool { guard encryptedValues[at: key] <= userModificationTime else { return false } - if encryptedValues[key] != nil { - encryptedValues[key] = nil - encryptedValues[at: key] = userModificationTime - self.userModificationTime = userModificationTime - return true - } else if self[key] != nil { - self[key] = nil - encryptedValues[at: key] = userModificationTime - self.userModificationTime = userModificationTime - return true + if encrypted { + if encryptedValues[key] != nil { + encryptedValues[key] = nil + encryptedValues[at: key] = userModificationTime + self.userModificationTime = userModificationTime + return true + } + } else { + if self[key] != nil { + self[key] = nil + encryptedValues[at: key] = userModificationTime + self.userModificationTime = userModificationTime + return true + } } return false } - func update(with row: T, userModificationTime: Int64) { + func update( + with row: T, + userModificationTime: Int64, + unencryptedColumnNames: Set = [] + ) { for column in T.TableColumns.writableColumns { func open(_ column: some WritableTableColumnExpression) { let keyPath = column.keyPath as! KeyPath let column = column as! any WritableTableColumnExpression let value = Value(queryOutput: row[keyPath: keyPath]) + let encrypted = !unencryptedColumnNames.contains(column.name) switch value.queryBinding { case .blob(let value): - setValue(value, forKey: column.name, at: userModificationTime) + setValue(value, forKey: column.name, at: userModificationTime, encrypted: encrypted) case .bool(let value): - setValue(value, forKey: column.name, at: userModificationTime) + setValue(value, forKey: column.name, at: userModificationTime, encrypted: encrypted) case .double(let value): - setValue(value, forKey: column.name, at: userModificationTime) + setValue(value, forKey: column.name, at: userModificationTime, encrypted: encrypted) case .date(let value): - setValue(value, forKey: column.name, at: userModificationTime) + setValue(value, forKey: column.name, at: userModificationTime, encrypted: encrypted) case .int(let value): - setValue(value, forKey: column.name, at: userModificationTime) + setValue(value, forKey: column.name, at: userModificationTime, encrypted: encrypted) case .null: - removeValue(forKey: column.name, at: userModificationTime) + removeValue(forKey: column.name, at: userModificationTime, encrypted: encrypted) case .text(let value): - setValue(value, forKey: column.name, at: userModificationTime) + setValue(value, forKey: column.name, at: userModificationTime, encrypted: encrypted) case .uint(let value): - setValue(value, forKey: column.name, at: userModificationTime) + setValue(value, forKey: column.name, at: userModificationTime, encrypted: encrypted) case .uuid(let value): setValue( value.uuidString.lowercased(), forKey: column.name, - at: userModificationTime + at: userModificationTime, + encrypted: encrypted ) case .invalid(let error): reportIssue(error) @@ -278,7 +303,8 @@ with other: CKRecord, row: T, columnNames: inout [String], - parentForeignKey: ForeignKey? + parentForeignKey: ForeignKey?, + unencryptedColumnNames: Set = [] ) { typealias EquatableCKRecordValueProtocol = CKRecordValueProtocol & Equatable @@ -287,13 +313,18 @@ func open(_ column: some WritableTableColumnExpression) { let key = column.name let keyPath = column.keyPath as! KeyPath + let encrypted = !unencryptedColumnNames.contains(key) let didSet: Bool if let value = other[key] as? CKAsset { - didSet = setAsset(value, forKey: key, at: other.encryptedValues[at: key]) - } else if let value = other.encryptedValues[key] as? any EquatableCKRecordValueProtocol { - didSet = setValue(value, forKey: key, at: other.encryptedValues[at: key]) - } else if other.encryptedValues[key] == nil { - didSet = removeValue(forKey: key, at: other.encryptedValues[at: key]) + didSet = setAsset(value, forKey: key, at: other.encryptedValues[at: key], encrypted: encrypted) + } else if encrypted, let value = other.encryptedValues[key] as? any EquatableCKRecordValueProtocol { + didSet = setValue(value, forKey: key, at: other.encryptedValues[at: key], encrypted: true) + } else if !encrypted, let value = other[key] as? any EquatableCKRecordValueProtocol { + didSet = setValue(value, forKey: key, at: other.encryptedValues[at: key], encrypted: false) + } else if encrypted, other.encryptedValues[key] == nil { + didSet = removeValue(forKey: key, at: other.encryptedValues[at: key], encrypted: true) + } else if !encrypted, other[key] == nil { + didSet = removeValue(forKey: key, at: other.encryptedValues[at: key], encrypted: false) } else { didSet = false } @@ -303,21 +334,22 @@ case .blob(let value): return other.encryptedValues[hash: key] != value.sha256 case .bool(let value): - return other.encryptedValues[key] != value + return encrypted ? other.encryptedValues[key] != value : other[key] != value case .double(let value): - return other.encryptedValues[key] != value + return encrypted ? other.encryptedValues[key] != value : other[key] != value case .date(let value): - return other.encryptedValues[key] != value + return encrypted ? other.encryptedValues[key] != value : other[key] != value case .int(let value): - return other.encryptedValues[key] != value + return encrypted ? other.encryptedValues[key] != value : other[key] != value case .null: - return other.encryptedValues[key] != nil + return encrypted ? other.encryptedValues[key] != nil : other[key] != nil case .text(let value): - return other.encryptedValues[key] != value + return encrypted ? other.encryptedValues[key] != value : other[key] != value case .uint(let value): - return other.encryptedValues[key] != value + return encrypted ? other.encryptedValues[key] != value : other[key] != value case .uuid(let value): - return other.encryptedValues[key] != value.uuidString.lowercased() + let uuidString = value.uuidString.lowercased() + return encrypted ? other.encryptedValues[key] != uuidString : other[key] != uuidString case .invalid(let error): reportIssue(error) return false diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index f4b97a0b..c1fffda4 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -13,6 +13,64 @@ import UIKit #endif + /// Specifies which fields should be encrypted when syncing to CloudKit. + /// + /// By default, all fields are encrypted using CloudKit's `encryptedValues` container. + /// Use this type to specify which fields should remain unencrypted for querying, + /// indexing, and subscriptions. + /// + /// ```swift + /// // All fields encrypted (default) + /// try SyncEngine(for: database, tables: Reminder.self) + /// + /// // No fields encrypted + /// try SyncEngine(for: database, tables: Reminder.self, encryptedFields: .none) + /// + /// // Only specific fields encrypted + /// try SyncEngine( + /// for: database, + /// tables: Reminder.self, + /// encryptedFields: .only(Reminder.notes) + /// ) + /// ``` + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public struct EncryptedFields: Sendable { + enum Storage: Sendable { + case all + case none + case only([String: Set]) + } + let storage: Storage + + /// All fields are encrypted (default). + public static var all: EncryptedFields { .init(storage: .all) } + + /// No fields are encrypted. + public static var none: EncryptedFields { .init(storage: .none) } + + /// Only the specified fields are encrypted; all others are unencrypted. + public static func only(_ fields: repeat each F) -> EncryptedFields { + var columnsByTable: [String: Set] = [:] + for field in repeat each fields { + columnsByTable[field._fieldTableName, default: []].insert(field._fieldColumnName) + } + return .init(storage: .only(columnsByTable)) + } + } + + /// A protocol for column expressions that can specify field encryption. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public protocol FieldConvertible { + var _fieldTableName: String { get } + var _fieldColumnName: String { get } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension TableColumn: FieldConvertible where Root: PrimaryKeyedTable { + public var _fieldTableName: String { Root.tableName } + public var _fieldColumnName: String { name } + } + /// An object that manages the synchronization of local and remote SQLite data. /// /// See for more information. @@ -70,6 +128,7 @@ /// shareable with other users on CloudKit. /// - privateTables: A list of tables that you want to synchronize to CloudKit but that /// you do not want to be shareable with other users. + /// - encryptedFields: Specifies which fields should be encrypted. Defaults to `.all`. /// - containerIdentifier: The container identifier in CloudKit to synchronize to. If omitted /// the container will be determined from the entitlements of your app. /// - defaultZone: The zone for all records to be stored in. @@ -86,6 +145,7 @@ for database: any DatabaseWriter, tables: repeat (each T1).Type, privateTables: repeat (each T2).Type, + encryptedFields: EncryptedFields = .all, containerIdentifier: String? = nil, defaultZone: CKRecordZone = CKRecordZone(zoneName: "co.pointfree.SQLiteData.defaultZone"), startImmediately: Bool = true, @@ -103,13 +163,55 @@ containerIdentifier ?? ModelConfiguration(groupContainer: .automatic).cloudKitContainerIdentifier + // Compute unencrypted columns based on encryptedFields + var unencryptedColumnsByTable: [String: Set] = [:] + switch encryptedFields.storage { + case .all: + // All fields encrypted, no unencrypted columns + break + case .none: + // No fields encrypted, all columns unencrypted + for table in repeat each tables { + for column in table.TableColumns.writableColumns { + unencryptedColumnsByTable[table.tableName, default: []].insert(column.name) + } + } + for table in repeat each privateTables { + for column in table.TableColumns.writableColumns { + unencryptedColumnsByTable[table.tableName, default: []].insert(column.name) + } + } + case .only(let encryptedColumnsByTable): + // Only specified fields encrypted, all others unencrypted + for table in repeat each tables { + var columns: Set = [] + for column in table.TableColumns.writableColumns { + columns.insert(column.name) + } + let encryptedColumns = encryptedColumnsByTable[table.tableName] ?? [] + unencryptedColumnsByTable[table.tableName] = columns.subtracting(encryptedColumns) + } + for table in repeat each privateTables { + var columns: Set = [] + for column in table.TableColumns.writableColumns { + columns.insert(column.name) + } + let encryptedColumns = encryptedColumnsByTable[table.tableName] ?? [] + unencryptedColumnsByTable[table.tableName] = columns.subtracting(encryptedColumns) + } + } + var allTables: [any SynchronizableTable] = [] var allPrivateTables: [any SynchronizableTable] = [] for table in repeat each tables { - allTables.append(SynchronizedTable(for: table)) + let unencryptedColumns = unencryptedColumnsByTable[table.tableName] ?? [] + allTables.append(SynchronizedTable(for: table, unencryptedColumnNames: unencryptedColumns)) } for privateTable in repeat each privateTables { - allPrivateTables.append(SynchronizedTable(for: privateTable)) + let unencryptedColumns = unencryptedColumnsByTable[privateTable.tableName] ?? [] + allPrivateTables.append( + SynchronizedTable(for: privateTable, unencryptedColumnNames: unencryptedColumns) + ) } let userDatabase = UserDatabase(database: database) @@ -1128,7 +1230,7 @@ missingTable = recordID return nil } - func open(_: some SynchronizableTable) async -> CKRecord? { + func open(_ table: some SynchronizableTable) async -> CKRecord? { let row = withErrorReporting(.sqliteDataCloudKitFailure) { try userDatabase.read { db in @@ -1169,7 +1271,8 @@ record.update( with: T(queryOutput: row), - userModificationTime: metadata.userModificationTime + userModificationTime: metadata.userModificationTime, + unencryptedColumnNames: table.unencryptedColumnNames ) await refreshLastKnownServerRecord(record) sentRecord = recordID @@ -1867,7 +1970,8 @@ columnNames: &columnNames, parentForeignKey: foreignKeysByTableName[T.tableName]?.count == 1 ? foreignKeysByTableName[T.tableName]?.first - : nil + : nil, + unencryptedColumnNames: table.unencryptedColumnNames ) } @@ -2256,6 +2360,7 @@ Base.PrimaryKey.QueryOutput: IdentifierStringConvertible, Base.TableColumns.PrimaryColumn: WritableTableColumnExpression var base: Base.Type { get } + var unencryptedColumnNames: Set { get } } package struct SynchronizedTable< @@ -2265,7 +2370,19 @@ Base.PrimaryKey.QueryOutput: IdentifierStringConvertible, Base.TableColumns.PrimaryColumn: WritableTableColumnExpression { - package init(for table: Base.Type = Base.self) {} + package let unencryptedColumnNames: Set + + package init(for table: Base.Type = Base.self) { + self.unencryptedColumnNames = [] + } + + package init( + for table: Base.Type = Base.self, + unencryptedColumnNames: Set + ) { + self.unencryptedColumnNames = unencryptedColumnNames + } + package var base: Base.Type { Base.self } } @@ -2351,10 +2468,11 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) private func upsert( - _: some SynchronizableTable, + _ table: some SynchronizableTable, record: CKRecord, columnNames: some Collection ) -> QueryFragment { + let unencryptedColumnNames = table.unencryptedColumnNames let allColumnNames = T.TableColumns.writableColumns.map(\.name) let hasNonPrimaryKeyColumns = columnNames.contains { $0 != T.primaryKey.name } var query: QueryFragment = "INSERT INTO \(T.self) (" @@ -2367,6 +2485,8 @@ @Dependency(\.dataManager) var dataManager return (try? asset.fileURL.map { try dataManager.load($0) })? .queryFragment ?? "NULL" + } else if unencryptedColumnNames.contains(columnName) { + return record[columnName]?.queryFragment ?? "NULL" } else { return record.encryptedValues[columnName]?.queryFragment ?? "NULL" } diff --git a/Tests/SQLiteDataTests/CloudKitTests/UnencryptedFieldsTests.swift b/Tests/SQLiteDataTests/CloudKitTests/UnencryptedFieldsTests.swift new file mode 100644 index 00000000..bceefaaf --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/UnencryptedFieldsTests.swift @@ -0,0 +1,566 @@ +#if canImport(CloudKit) + import CloudKit + import CustomDump + import DependenciesTestSupport + import Foundation + import InlineSnapshotTesting + import OrderedCollections + import SQLiteData + import SQLiteDataTestSupport + import SnapshotTestingCustomDump + import Testing + import os + + @MainActor + @Suite( + .snapshots(record: .missing), + .dependencies { + $0.currentTime.now = 0 + $0.dataManager = InMemoryDataManager() + }, + .attachMetadatabase(false) + ) + final class UnencryptedFieldsTests: @unchecked Sendable { + let userDatabase: UserDatabase + private let _syncEngine: any Sendable + private let _container: any Sendable + + @Dependency(\.currentTime.now) var now + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + var container: MockCloudContainer { + _container as! MockCloudContainer + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + var syncEngine: SyncEngine { + _syncEngine as! SyncEngine + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + init() async throws { + let testContainerIdentifier = "iCloud.co.pointfree.Testing.\(UUID())" + + self.userDatabase = UserDatabase( + database: try SQLiteDataTests.database( + containerIdentifier: testContainerIdentifier, + attachMetadatabase: false + ) + ) + let privateDatabase = MockCloudDatabase(databaseScope: .private) + let sharedDatabase = MockCloudDatabase(databaseScope: .shared) + let container = MockCloudContainer( + accountStatus: .available, + containerIdentifier: testContainerIdentifier, + privateCloudDatabase: privateDatabase, + sharedCloudDatabase: sharedDatabase + ) + _container = container + privateDatabase.set(container: container) + sharedDatabase.set(container: container) + + // Create sync engine with only isCompleted and remindersListID encrypted (title unencrypted) + _syncEngine = try await SyncEngine( + container: container, + userDatabase: userDatabase, + delegate: nil, + tables: [ + SynchronizedTable(for: Reminder.self, unencryptedColumnNames: ["title"]), + SynchronizedTable(for: RemindersList.self, unencryptedColumnNames: ["title"]), + ], + privateTables: [ + SynchronizedTable(for: RemindersListPrivate.self, unencryptedColumnNames: []), + ], + startImmediately: true + ) + + await syncEngine.handleEvent( + .accountChange(changeType: .signIn(currentUser: CKRecord.ID(recordName: "currentUser"))), + syncEngine: syncEngine.private + ) + await syncEngine.handleEvent( + .accountChange(changeType: .signIn(currentUser: CKRecord.ID(recordName: "currentUser"))), + syncEngine: syncEngine.shared + ) + try await syncEngine.processPendingDatabaseChanges(scope: .private) + } + + deinit { + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + let syncEngine = _syncEngine as! SyncEngine + guard syncEngine.isRunning + else { return } + + syncEngine.shared.assertFetchChangesScopes([]) + syncEngine.shared.state.assertPendingDatabaseChanges([]) + syncEngine.shared.state.assertPendingRecordZoneChanges([]) + syncEngine.shared.assertAcceptedShareMetadata([]) + syncEngine.private.assertFetchChangesScopes([]) + syncEngine.private.state.assertPendingDatabaseChanges([]) + syncEngine.private.state.assertPendingRecordZoneChanges([]) + syncEngine.private.assertAcceptedShareMetadata([]) + + try! syncEngine.metadatabase.read { db in + try #expect(UnsyncedRecordID.count().fetchOne(db) == 0) + } + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func unencryptedFieldsStoredInPlainRecord() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Get the reminder record from the mock database + let reminderRecord = try syncEngine.private.database + .record(for: Reminder.recordID(for: 1)) + + // Verify the title is stored in the unencrypted part (direct record access) + #expect(reminderRecord["title"] as? String == "Get milk") + + // Verify the title is NOT stored in encryptedValues + #expect(reminderRecord.encryptedValues["title"] == nil) + + // Verify encrypted fields (like isCompleted) are still stored in encryptedValues + #expect(reminderRecord.encryptedValues["isCompleted"] != nil) + #expect(reminderRecord["isCompleted"] == nil) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func mixedEncryptedAndUnencryptedFields() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, isCompleted: true, title: "Get milk", remindersListID: 1) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let reminderRecord = try syncEngine.private.database + .record(for: Reminder.recordID(for: 1)) + + // Unencrypted field: title + #expect(reminderRecord["title"] as? String == "Get milk") + #expect(reminderRecord.encryptedValues["title"] == nil) + + // Encrypted fields: isCompleted, remindersListID, id + #expect(reminderRecord.encryptedValues["isCompleted"] as? Int64 == 1) + #expect(reminderRecord["isCompleted"] == nil) + + #expect(reminderRecord.encryptedValues["remindersListID"] as? Int64 == 1) + #expect(reminderRecord["remindersListID"] == nil) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func remoteChangeToUnencryptedField() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Simulate a remote change to the unencrypted title field + let reminderRecord = try syncEngine.private.database + .record(for: Reminder.recordID(for: 1)) + + // Set the unencrypted field value directly (simulating what CloudKit would do) + reminderRecord["title"] = "Get bread" + reminderRecord.encryptedValues["\(CKRecord.userModificationTimeKey)_title"] = Int64(100) + reminderRecord.encryptedValues[CKRecord.userModificationTimeKey] = Int64(100) + + try await syncEngine.modifyRecords(scope: .private, saving: [reminderRecord]).notify() + + // Verify the local database is updated + let updatedReminder = try await userDatabase.read { db in + try Reminder.find(1).fetchOne(db) + } + #expect(updatedReminder?.title == "Get bread") + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func multipleTablesWithUnencryptedFields() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + RemindersList(id: 2, title: "Work") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + Reminder(id: 2, title: "Buy groceries", remindersListID: 2) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Verify RemindersList.title is unencrypted + let remindersList1 = try syncEngine.private.database + .record(for: RemindersList.recordID(for: 1)) + #expect(remindersList1["title"] as? String == "Personal") + #expect(remindersList1.encryptedValues["title"] == nil) + + let remindersList2 = try syncEngine.private.database + .record(for: RemindersList.recordID(for: 2)) + #expect(remindersList2["title"] as? String == "Work") + #expect(remindersList2.encryptedValues["title"] == nil) + + // Verify Reminder.title is unencrypted + let reminder1 = try syncEngine.private.database + .record(for: Reminder.recordID(for: 1)) + #expect(reminder1["title"] as? String == "Get milk") + #expect(reminder1.encryptedValues["title"] == nil) + + let reminder2 = try syncEngine.private.database + .record(for: Reminder.recordID(for: 2)) + #expect(reminder2["title"] as? String == "Buy groceries") + #expect(reminder2.encryptedValues["title"] == nil) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func updateUnencryptedField() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, title: "Get milk", remindersListID: 1) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Update the local database + try await withDependencies { + $0.currentTime.now += 1 + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(1).update { $0.title = "Get bread" }.execute(db) + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + } + + // Verify the CloudKit record has the updated unencrypted title + let reminderRecord = try syncEngine.private.database + .record(for: Reminder.recordID(for: 1)) + #expect(reminderRecord["title"] as? String == "Get bread") + #expect(reminderRecord.encryptedValues["title"] == nil) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func nullUnencryptedField() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, title: "", remindersListID: 1) // Empty string (no nil in this schema) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let reminderRecord = try syncEngine.private.database + .record(for: Reminder.recordID(for: 1)) + + // Empty string should still be stored unencrypted + #expect(reminderRecord["title"] as? String == "") + #expect(reminderRecord.encryptedValues["title"] == nil) + } + } + + /// Tests for the `encryptedFields:` init that defaults all fields to unencrypted. + @MainActor + @Suite( + .snapshots(record: .missing), + .dependencies { + $0.currentTime.now = 0 + $0.dataManager = InMemoryDataManager() + }, + .attachMetadatabase(false) + ) + final class EncryptedFieldsTests: @unchecked Sendable { + let userDatabase: UserDatabase + private let _syncEngine: any Sendable + private let _container: any Sendable + + @Dependency(\.currentTime.now) var now + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + var container: MockCloudContainer { + _container as! MockCloudContainer + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + var syncEngine: SyncEngine { + _syncEngine as! SyncEngine + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + init() async throws { + let testContainerIdentifier = "iCloud.co.pointfree.Testing.\(UUID())" + + self.userDatabase = UserDatabase( + database: try SQLiteDataTests.database( + containerIdentifier: testContainerIdentifier, + attachMetadatabase: false + ) + ) + let privateDatabase = MockCloudDatabase(databaseScope: .private) + let sharedDatabase = MockCloudDatabase(databaseScope: .shared) + let container = MockCloudContainer( + accountStatus: .available, + containerIdentifier: testContainerIdentifier, + privateCloudDatabase: privateDatabase, + sharedCloudDatabase: sharedDatabase + ) + _container = container + privateDatabase.set(container: container) + sharedDatabase.set(container: container) + + // Create sync engine with only isCompleted encrypted, others are unencrypted + _syncEngine = try await SyncEngine( + container: container, + userDatabase: userDatabase, + delegate: nil, + tables: [ + SynchronizedTable( + for: Reminder.self, + unencryptedColumnNames: ["id", "title", "remindersListID"] + ), + SynchronizedTable( + for: RemindersList.self, + unencryptedColumnNames: ["id", "title"] + ), + ], + privateTables: [ + SynchronizedTable( + for: RemindersListPrivate.self, + unencryptedColumnNames: ["id", "title"] + ), + ], + startImmediately: true + ) + + await syncEngine.handleEvent( + .accountChange(changeType: .signIn(currentUser: CKRecord.ID(recordName: "currentUser"))), + syncEngine: syncEngine.private + ) + await syncEngine.handleEvent( + .accountChange(changeType: .signIn(currentUser: CKRecord.ID(recordName: "currentUser"))), + syncEngine: syncEngine.shared + ) + try await syncEngine.processPendingDatabaseChanges(scope: .private) + } + + deinit { + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + let syncEngine = _syncEngine as! SyncEngine + guard syncEngine.isRunning + else { return } + + syncEngine.shared.assertFetchChangesScopes([]) + syncEngine.shared.state.assertPendingDatabaseChanges([]) + syncEngine.shared.state.assertPendingRecordZoneChanges([]) + syncEngine.shared.assertAcceptedShareMetadata([]) + syncEngine.private.assertFetchChangesScopes([]) + syncEngine.private.state.assertPendingDatabaseChanges([]) + syncEngine.private.state.assertPendingRecordZoneChanges([]) + syncEngine.private.assertAcceptedShareMetadata([]) + + try! syncEngine.metadatabase.read { db in + try #expect(UnsyncedRecordID.count().fetchOne(db) == 0) + } + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func defaultUnencryptedWithSpecificEncrypted() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, isCompleted: true, title: "Get milk", remindersListID: 1) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let reminderRecord = try syncEngine.private.database + .record(for: Reminder.recordID(for: 1)) + + // isCompleted should be encrypted (specified in encryptedFields) + #expect(reminderRecord.encryptedValues["isCompleted"] as? Int64 == 1) + #expect(reminderRecord["isCompleted"] == nil) + + // title should be unencrypted (not in encryptedFields) + #expect(reminderRecord["title"] as? String == "Get milk") + #expect(reminderRecord.encryptedValues["title"] == nil) + + // remindersListID should be unencrypted (not in encryptedFields) + #expect(reminderRecord["remindersListID"] as? Int64 == 1) + #expect(reminderRecord.encryptedValues["remindersListID"] == nil) + + // id should be unencrypted (not in encryptedFields) + #expect(reminderRecord["id"] as? Int64 == 1) + #expect(reminderRecord.encryptedValues["id"] == nil) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func listFieldsDefaultUnencrypted() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let listRecord = try syncEngine.private.database + .record(for: RemindersList.recordID(for: 1)) + + // RemindersList has no fields in encryptedFields, so all should be unencrypted + #expect(listRecord["title"] as? String == "Personal") + #expect(listRecord.encryptedValues["title"] == nil) + + #expect(listRecord["id"] as? Int64 == 1) + #expect(listRecord.encryptedValues["id"] == nil) + } + } + + /// Tests for the `allFieldsEncrypted: false` init that makes everything unencrypted. + @MainActor + @Suite( + .snapshots(record: .missing), + .dependencies { + $0.currentTime.now = 0 + $0.dataManager = InMemoryDataManager() + }, + .attachMetadatabase(false) + ) + final class AllFieldsUnencryptedTests: @unchecked Sendable { + let userDatabase: UserDatabase + private let _syncEngine: any Sendable + private let _container: any Sendable + + @Dependency(\.currentTime.now) var now + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + var container: MockCloudContainer { + _container as! MockCloudContainer + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + var syncEngine: SyncEngine { + _syncEngine as! SyncEngine + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + init() async throws { + let testContainerIdentifier = "iCloud.co.pointfree.Testing.\(UUID())" + + self.userDatabase = UserDatabase( + database: try SQLiteDataTests.database( + containerIdentifier: testContainerIdentifier, + attachMetadatabase: false + ) + ) + let privateDatabase = MockCloudDatabase(databaseScope: .private) + let sharedDatabase = MockCloudDatabase(databaseScope: .shared) + let container = MockCloudContainer( + accountStatus: .available, + containerIdentifier: testContainerIdentifier, + privateCloudDatabase: privateDatabase, + sharedCloudDatabase: sharedDatabase + ) + _container = container + privateDatabase.set(container: container) + sharedDatabase.set(container: container) + + // Create sync engine with everything unencrypted + _syncEngine = try await SyncEngine( + container: container, + userDatabase: userDatabase, + delegate: nil, + tables: [ + SynchronizedTable( + for: Reminder.self, + unencryptedColumnNames: ["id", "isCompleted", "title", "remindersListID"] + ), + SynchronizedTable( + for: RemindersList.self, + unencryptedColumnNames: ["id", "title"] + ), + ], + privateTables: [ + SynchronizedTable( + for: RemindersListPrivate.self, + unencryptedColumnNames: ["id", "title"] + ), + ], + startImmediately: true + ) + + await syncEngine.handleEvent( + .accountChange(changeType: .signIn(currentUser: CKRecord.ID(recordName: "currentUser"))), + syncEngine: syncEngine.private + ) + await syncEngine.handleEvent( + .accountChange(changeType: .signIn(currentUser: CKRecord.ID(recordName: "currentUser"))), + syncEngine: syncEngine.shared + ) + try await syncEngine.processPendingDatabaseChanges(scope: .private) + } + + deinit { + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + let syncEngine = _syncEngine as! SyncEngine + guard syncEngine.isRunning + else { return } + + syncEngine.shared.assertFetchChangesScopes([]) + syncEngine.shared.state.assertPendingDatabaseChanges([]) + syncEngine.shared.state.assertPendingRecordZoneChanges([]) + syncEngine.shared.assertAcceptedShareMetadata([]) + syncEngine.private.assertFetchChangesScopes([]) + syncEngine.private.state.assertPendingDatabaseChanges([]) + syncEngine.private.state.assertPendingRecordZoneChanges([]) + syncEngine.private.assertAcceptedShareMetadata([]) + + try! syncEngine.metadatabase.read { db in + try #expect(UnsyncedRecordID.count().fetchOne(db) == 0) + } + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func allFieldsStoredUnencrypted() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, isCompleted: true, title: "Get milk", remindersListID: 1) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let reminderRecord = try syncEngine.private.database + .record(for: Reminder.recordID(for: 1)) + + // All fields should be unencrypted + #expect(reminderRecord["id"] as? Int64 == 1) + #expect(reminderRecord.encryptedValues["id"] == nil) + + #expect(reminderRecord["title"] as? String == "Get milk") + #expect(reminderRecord.encryptedValues["title"] == nil) + + #expect(reminderRecord["isCompleted"] as? Int64 == 1) + #expect(reminderRecord.encryptedValues["isCompleted"] == nil) + + #expect(reminderRecord["remindersListID"] as? Int64 == 1) + #expect(reminderRecord.encryptedValues["remindersListID"] == nil) + + // Also check the list record + let listRecord = try syncEngine.private.database + .record(for: RemindersList.recordID(for: 1)) + + #expect(listRecord["id"] as? Int64 == 1) + #expect(listRecord.encryptedValues["id"] == nil) + + #expect(listRecord["title"] as? String == "Personal") + #expect(listRecord.encryptedValues["title"] == nil) + } + } +#endif