diff --git a/bitchat/Services/BLE/BLEFileTransferHandler.swift b/bitchat/Services/BLE/BLEFileTransferHandler.swift index f2e17b6d..a1833790 100644 --- a/bitchat/Services/BLE/BLEFileTransferHandler.swift +++ b/bitchat/Services/BLE/BLEFileTransferHandler.swift @@ -40,6 +40,9 @@ struct BLEFileTransferHandlerEnvironment { let commitPrivateMediaFile: (_ messageID: String, _ storedURL: URL) -> Bool /// Rolls back a saved payload when its durable receipt commit fails. let removeIncomingFile: (_ storedURL: URL) -> Void + /// Releases the allocator's save-to-UI ownership guard after synchronous + /// conversation insertion has completed. + let finishIncomingFileDelivery: (_ storedURL: URL) -> Void /// Checks the authenticated sender before any private-media disk work. let isPrivateMediaSenderBlocked: (PeerID) -> Bool /// Updates the registry last-seen timestamp for the peer (async barrier write). @@ -49,11 +52,14 @@ struct BLEFileTransferHandlerEnvironment { let acknowledgePrivateMedia: (_ messageID: String, _ peerID: PeerID) -> Void /// Delivers `.messageReceived` as one main-actor hop while /// `shouldDeliver` remains true before and after the synchronous sink. - /// The completion authorizes the stable-media ACK. + /// The completion authorizes the stable-media ACK. Finalization runs after + /// every delivery attempt, including rejection, so allocator ownership + /// cannot leak indefinitely. let deliverMessage: ( _ message: BitchatMessage, _ shouldDeliver: @escaping () -> Bool, - _ completion: @escaping () -> Void + _ completion: @escaping () -> Void, + _ finalization: @escaping (TransportEventDeliveryOutcome) -> Void ) -> Void } @@ -357,7 +363,24 @@ final class BLEFileTransferHandler { env: env ) } else { - env.deliverMessage(message, { true }, {}) + env.deliverMessage( + message, + { true }, + {}, + { outcome in + if outcome == .rejected { + // Raw media has no durable receipt that can redeliver + // it later. Do not leave a newly saved, UI-unowned file + // available for a stale fallback path to misidentify. + env.removeIncomingFile(destination) + } else { + // Plain delegates are invoked without synchronous + // insertion confirmation. Preserve the payload for + // that supported delivery path. + env.finishIncomingFileDelivery(destination) + } + } + ) } return true } @@ -381,6 +404,9 @@ final class BLEFileTransferHandler { }, { env.acknowledgePrivateMedia(messageID, peerID) + }, + { _ in + env.finishIncomingFileDelivery(expectedURL) } ) } diff --git a/bitchat/Services/BLE/BLEIncomingFileStore.swift b/bitchat/Services/BLE/BLEIncomingFileStore.swift index a16efa87..5e89393c 100644 --- a/bitchat/Services/BLE/BLEIncomingFileStore.swift +++ b/bitchat/Services/BLE/BLEIncomingFileStore.swift @@ -93,7 +93,7 @@ struct PanicRecoveryOperations { } } -struct BLEIncomingFileStore { +struct BLEIncomingFileStore: @unchecked Sendable { enum PanicRecoveryError: Error { case externalMarkerCommitFailed case markerWriteFailed(Error) @@ -103,7 +103,17 @@ struct BLEIncomingFileStore { ) } - private static let quotaBytes: Int64 = 100 * 1024 * 1024 + struct PrivateMediaDeletionReservation: Sendable { + fileprivate let id: UUID + } + + private final class PayloadCoordination: @unchecked Sendable { + let lock = NSLock() + var pendingDeliveryPaths: Set = [] + var deletionReservations: [UUID: Set] = [:] + } + + private static let defaultQuotaBytes: Int64 = 100 * 1024 * 1024 /// Kept outside `files/` so deleting the media tree cannot erase the /// fail-closed startup decision before the full panic has committed. private static let panicRecoveryPendingMarkerFileName = @@ -120,7 +130,6 @@ struct BLEIncomingFileStore { "files/incoming", "files/outgoing" ] - /// Name prefix of in-flight live voice captures (progressively written by /// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern — /// deleting one mid-stream unlinks the inode under an open `FileHandle` @@ -134,7 +143,9 @@ struct BLEIncomingFileStore { private let baseDirectory: URL? private let dateProvider: () -> Date private let panicMarkerWriter: (Data, URL) throws -> Void + private let quotaBytes: Int64 private let privateMediaReceipts: BLEPrivateMediaReceiptStore + private let payloadCoordination: PayloadCoordination init( fileManager: FileManager = .default, @@ -142,17 +153,20 @@ struct BLEIncomingFileStore { dateProvider: @escaping () -> Date = Date.init, panicMarkerWriter: @escaping (Data, URL) throws -> Void = { try $0.write(to: $1, options: .atomic) - } + }, + quotaBytes: Int64 = Self.defaultQuotaBytes ) { self.fileManager = fileManager self.baseDirectory = baseDirectory self.dateProvider = dateProvider self.panicMarkerWriter = panicMarkerWriter + self.quotaBytes = max(0, quotaBytes) self.privateMediaReceipts = BLEPrivateMediaReceiptStore( fileManager: fileManager, baseDirectory: baseDirectory, now: dateProvider ) + self.payloadCoordination = PayloadCoordination() } /// Panic-wipe every managed incoming and outgoing media artifact before @@ -251,6 +265,9 @@ struct BLEIncomingFileStore { fallbackExtension: String?, defaultPrefix: String ) -> URL? { + payloadCoordination.lock.lock() + defer { payloadCoordination.lock.unlock() } + do { let base = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true) try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil) @@ -259,8 +276,26 @@ struct BLEIncomingFileStore { defaultName: "\(defaultPrefix)_\(Self.timestampString(from: dateProvider()))", fallbackExtension: fallbackExtension ) - let destination = uniqueFileURL(in: base, fileName: sanitized) + let reservedPaths = privateMediaReceipts.reservedPayloadPaths() + let deletionPaths = payloadCoordination + .deletionReservations.values.reduce(into: Set()) { + $0.formUnion($1) + } + let allocationReservations = deletionPaths.union( + payloadCoordination.pendingDeliveryPaths + ) + let destination = uniqueFileURL( + in: base, + fileName: sanitized, + reservedPaths: (reservedPaths ?? []).union( + allocationReservations + ), + forceRandomizedName: reservedPaths == nil + ) try data.write(to: destination, options: .atomic) + payloadCoordination.pendingDeliveryPaths.insert( + destination.standardizedFileURL.path + ) return destination } catch { SecureLogger.error("❌ Failed to persist incoming media: \(error)", category: .session) @@ -284,8 +319,75 @@ struct BLEIncomingFileStore { ) } + /// Reserves every receipt/UI path before the asynchronous deletion + /// barrier. Allocation and reservation share one lock, so either an + /// in-flight raw arrival is observed and deletion fails closed, or the + /// arrival is forced onto a different filename. + func reservePrivateMediaDeletion( + messageIDs: [String], + payloadRelativePaths: [String: String] + ) -> PrivateMediaDeletionReservation? { + payloadCoordination.lock.lock() + defer { payloadCoordination.lock.unlock() } + + guard let paths = privateMediaReceipts + .prospectiveDeletionPayloadPaths( + messageIDs: messageIDs, + payloadRelativePaths: payloadRelativePaths + ), + paths.isDisjoint( + with: payloadCoordination.pendingDeliveryPaths + ) else { + return nil + } + + let reservation = PrivateMediaDeletionReservation(id: UUID()) + payloadCoordination.deletionReservations[reservation.id] = paths + return reservation + } + + func commitPrivateMediaDeletion( + reservation: PrivateMediaDeletionReservation, + messageIDs: [String], + payloadRelativePaths: [String: String], + protectedPayloadRelativePaths: Set + ) -> Bool { + payloadCoordination.lock.lock() + defer { + payloadCoordination.deletionReservations.removeValue( + forKey: reservation.id + ) + payloadCoordination.lock.unlock() + } + guard payloadCoordination.deletionReservations[reservation.id] != nil + else { + return false + } + return privateMediaReceipts.recordDeleted( + messageIDs: messageIDs, + payloadRelativePaths: payloadRelativePaths, + protectedPayloadRelativePaths: protectedPayloadRelativePaths + ) + } + + /// Releases the short window between disk save and synchronous + /// conversation insertion. Before this callback, a deletion transaction + /// may not infer ownership from a stale bubble that names the same path. + func finishIncomingFileDelivery(at storedURL: URL) { + payloadCoordination.lock.lock() + defer { payloadCoordination.lock.unlock() } + payloadCoordination.pendingDeliveryPaths.remove( + storedURL.standardizedFileURL.path + ) + } + /// Best-effort rollback for a payload whose durable receipt commit failed. func removeIncomingFile(at storedURL: URL) { + payloadCoordination.lock.lock() + defer { payloadCoordination.lock.unlock() } + payloadCoordination.pendingDeliveryPaths.remove( + storedURL.standardizedFileURL.path + ) guard isURLInsideFilesDirectory(storedURL) else { return } do { try fileManager.removeItem(at: storedURL) @@ -303,6 +405,9 @@ struct BLEIncomingFileStore { /// a finalized transfer can arrive at quota while a burst is still /// streaming — but they still count toward usage. func enforceQuota(reservingBytes: Int) { + payloadCoordination.lock.lock() + defer { payloadCoordination.lock.unlock() } + do { let base = try filesDirectory() let incomingDirs = [ @@ -328,14 +433,26 @@ struct BLEIncomingFileStore { } let currentUsage = allFiles.reduce(0) { $0 + $1.size } - let targetUsage = Self.quotaBytes - Int64(reservingBytes) + let targetUsage = quotaBytes - Int64(reservingBytes) guard currentUsage > targetUsage else { return } let needToFree = currentUsage - targetUsage + let activeDeletionPaths = payloadCoordination + .deletionReservations.values.reduce(into: Set()) { + $0.formUnion($1) + } + let protectedPaths = activeDeletionPaths.union( + payloadCoordination.pendingDeliveryPaths + ) var freedSpace: Int64 = 0 for file in allFiles.sorted(by: { $0.modified < $1.modified }) { guard freedSpace < needToFree else { break } guard !file.url.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue } + guard !protectedPaths.contains( + file.url.standardizedFileURL.path + ) else { + continue + } do { try fileManager.removeItem(at: file.url) freedSpace += file.size @@ -423,11 +540,20 @@ struct BLEIncomingFileStore { return candidate.isEmpty ? defaultName : candidate } - private func uniqueFileURL(in directory: URL, fileName: String) -> URL { + private func uniqueFileURL( + in directory: URL, + fileName: String, + reservedPaths: Set, + forceRandomizedName: Bool + ) -> URL { let directoryPath = directory.standardizedFileURL.path func isInsideDirectory(_ url: URL) -> Bool { url.standardizedFileURL.path.hasPrefix(directoryPath + "/") } + func isAvailable(_ url: URL) -> Bool { + !reservedPaths.contains(url.standardizedFileURL.path) + && !fileManager.fileExists(atPath: url.path) + } var candidate = directory.appendingPathComponent(fileName) guard isInsideDirectory(candidate) else { @@ -435,19 +561,27 @@ struct BLEIncomingFileStore { return directory.appendingPathComponent("blocked_\(UUID().uuidString)") } - if !fileManager.fileExists(atPath: candidate.path) { + let baseName = (fileName as NSString).deletingPathExtension + let ext = (fileName as NSString).pathExtension + if forceRandomizedName { + let suffix = UUID().uuidString + let randomizedName = ext.isEmpty + ? "\(baseName)_\(suffix)" + : "\(baseName)_\(suffix).\(ext)" + return directory.appendingPathComponent(randomizedName) + } + + if isAvailable(candidate) { return candidate } - let baseName = (fileName as NSString).deletingPathExtension - let ext = (fileName as NSString).pathExtension for counter in 1..<100 { let newName = ext.isEmpty ? "\(baseName) (\(counter))" : "\(baseName) (\(counter)).\(ext)" candidate = directory.appendingPathComponent(newName) guard isInsideDirectory(candidate) else { return directory.appendingPathComponent("blocked_\(UUID().uuidString)") } - if !fileManager.fileExists(atPath: candidate.path) { + if isAvailable(candidate) { return candidate } } diff --git a/bitchat/Services/BLE/BLEPrivateMediaReceiptStore.swift b/bitchat/Services/BLE/BLEPrivateMediaReceiptStore.swift index bbbc492c..a6d57eb0 100644 --- a/bitchat/Services/BLE/BLEPrivateMediaReceiptStore.swift +++ b/bitchat/Services/BLE/BLEPrivateMediaReceiptStore.swift @@ -23,7 +23,15 @@ enum BLEPrivateMediaReceiptState: Equatable { final class BLEPrivateMediaReceiptStore: @unchecked Sendable { typealias DirectoryReader = (_ directory: URL) throws -> [URL] typealias DataReader = (_ url: URL) throws -> Data + typealias DataWriter = ( + _ data: Data, + _ url: URL, + _ options: Data.WritingOptions + ) throws -> Void + typealias PayloadRemover = (_ url: URL) throws -> Void private static let receiptDirectoryName = ".private-media-receipts" + private static let deletionJournalFileName = ".deletion-journal.json" + private static let maximumDeletionPathsPerMessage = 2 private struct ReceiptRecord: Codable, Equatable { enum Kind: String, Codable { @@ -38,10 +46,23 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable { let recordedAt: Date } + /// One atomic write of this journal is the commit point for an entire + /// explicit-deletion batch. Per-ID records and payload unlinks are + /// idempotent materialization performed only after that commit. + private struct DeletionJournalEntry: Codable, Equatable { + let relativePaths: [String] + let recordedAt: Date + } + + private struct DeletionJournal: Codable { + let version: Int + let entries: [String: DeletionJournalEntry] + } + private final class Runtime: @unchecked Sendable { let lock = NSLock() var records: [String: ReceiptRecord]? - var volatileTombstones: [String: Date] = [:] + var deletionJournal: [String: DeletionJournalEntry]? } private let fileManager: FileManager @@ -51,6 +72,8 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable { private let now: () -> Date private let directoryReader: DirectoryReader? private let dataReader: DataReader? + private let dataWriter: DataWriter + private let payloadRemover: PayloadRemover private let runtime = Runtime() init( @@ -60,7 +83,11 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable { ttl: TimeInterval = TransportConfig.privateMediaReceivedLedgerTTLSeconds, now: @escaping () -> Date = Date.init, directoryReader: DirectoryReader? = nil, - dataReader: DataReader? = nil + dataReader: DataReader? = nil, + dataWriter: @escaping DataWriter = { + try $0.write(to: $1, options: $2) + }, + payloadRemover: PayloadRemover? = nil ) { self.fileManager = fileManager self.baseDirectory = baseDirectory @@ -69,6 +96,10 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable { self.now = now self.directoryReader = directoryReader self.dataReader = dataReader + self.dataWriter = dataWriter + self.payloadRemover = payloadRemover ?? { + try fileManager.removeItem(at: $0) + } } /// Drops process-lifetime decisions after the enclosing media directory @@ -78,7 +109,7 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable { func resetForPanic() { runtime.lock.lock() runtime.records = nil - runtime.volatileTombstones.removeAll(keepingCapacity: false) + runtime.deletionJournal = nil runtime.lock.unlock() } @@ -91,29 +122,52 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable { defer { runtime.lock.unlock() } let date = now() - if let tombstonedAt = runtime.volatileTombstones[messageID] { - if !isExpired(tombstonedAt, at: date) { - return .tombstoned - } - runtime.volatileTombstones.removeValue(forKey: messageID) - } - guard let directory = resolvedReceiptDirectory(), - var records = loadIndexIfNeeded(from: directory, at: date) else { + loadDurableStateIfNeeded(from: directory, at: date) else { return .unavailable } - guard let record = records[messageID] else { return .absent } + _ = recoverDeletionJournal(in: directory) + if runtime.deletionJournal?[messageID] != nil { + return .tombstoned + } + guard var records = runtime.records else { return .unavailable } + guard var record = records[messageID] else { return .absent } - if isExpired(record.recordedAt, at: date) { + if record.kind == .tombstone, record.relativePath != nil { + guard scrubLegacyTombstone( + record, + messageID: messageID, + in: directory, + records: &records + ) else { + return .tombstoned + } + guard let scrubbed = records[messageID] else { + return .unavailable + } + record = scrubbed + } + + let retainsAcceptedPath = + record.kind == .accepted + && record.relativePath.flatMap(existingPayload) != nil + if isExpired(record.recordedAt, at: date), + !retainsAcceptedPath { + guard removeRecord( + messageID: messageID, + from: directory + ) else { + return record.kind == .tombstone + ? .tombstoned + : .unavailable + } records.removeValue(forKey: messageID) runtime.records = records - removeRecord(messageID: messageID, from: directory) return .absent } switch record.kind { case .tombstone: - removePayloadRecordedByTombstone(record) return .tombstoned case .accepted: @@ -121,9 +175,14 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable { let existingURL = existingPayload(relativePath: relativePath) else { // Quota cleanup is not explicit deletion. Remove the stale // receipt so a sender retry can restore the payload and bubble. + guard removeRecord( + messageID: messageID, + from: directory + ) else { + return .unavailable + } records.removeValue(forKey: messageID) runtime.records = records - removeRecord(messageID: messageID, from: directory) return .absent } return .accepted(existingURL) @@ -144,13 +203,24 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable { defer { runtime.lock.unlock() } let date = now() - if let tombstonedAt = runtime.volatileTombstones[messageID], - !isExpired(tombstonedAt, at: date) { + guard let directory = resolvedReceiptDirectory(), + loadDurableStateIfNeeded(from: directory, at: date) else { return false } - - guard let directory = resolvedReceiptDirectory(), - var records = loadIndexIfNeeded(from: directory, at: date) else { + _ = recoverDeletionJournal(in: directory) + guard runtime.deletionJournal?[messageID] == nil, + var records = runtime.records else { + return false + } + if runtime.deletionJournal?.values.contains(where: { + $0.relativePaths.contains(relativePath) + }) == true { + return false + } + if records.contains(where: { existingMessageID, record in + existingMessageID != messageID + && record.relativePath == relativePath + }) { return false } if let existing = records[messageID], @@ -188,73 +258,232 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable { return true } - /// Foundation for explicit media deletion. This branch does not wire the - /// chat-clear UI; it only makes a tombstone durable and fail closed. - func recordDeleted(messageID: String) -> Bool { - guard PrivateMediaMessageIdentity.isStableID(messageID) else { - return false - } + /// Atomically commits explicit deletion for every stable ID in `messageIDs`. + /// + /// The journal is the single batch commit point: a failed write changes + /// neither durable nor in-memory receiver state, so callers must preserve + /// their bubbles. Once the write succeeds, every entry is tombstoned even + /// if the process exits before per-ID materialization or payload unlink. + /// Recovery retries both operations on the next lookup or launch. + func recordDeleted( + messageIDs: [String], + payloadRelativePaths: [String: String] = [:], + protectedPayloadRelativePaths: Set = [] + ) -> Bool { + let stableIDs = Array( + Set(messageIDs.filter(PrivateMediaMessageIdentity.isStableID)) + ).sorted() + guard !stableIDs.isEmpty else { return true } + guard stableIDs.count <= capacity else { return false } runtime.lock.lock() defer { runtime.lock.unlock() } let date = now() - addVolatileTombstone(messageID, at: date) - guard let directory = resolvedReceiptDirectory(), - var records = loadIndexIfNeeded(from: directory, at: date) else { - runtime.volatileTombstones.removeValue(forKey: messageID) + loadDurableStateIfNeeded(from: directory, at: date) else { return false } - if let existing = records[messageID], - existing.kind == .tombstone, - !isExpired(existing.recordedAt, at: date) { - runtime.volatileTombstones.removeValue(forKey: messageID) - removePayloadRecordedByTombstone(existing) + _ = recoverDeletionJournal(in: directory) + guard let records = runtime.records, + var journal = runtime.deletionJournal else { + return false + } + + let pendingIDs = Set(journal.keys) + let newIDs = stableIDs.filter { messageID in + if pendingIDs.contains(messageID) { return false } + if let current = records[messageID], + current.kind == .tombstone, + !isExpired(current.recordedAt, at: date) { + return false + } return true } - - let victim = capacityVictim( - for: .tombstone, - replacing: messageID, - in: records - ) - if records[messageID]?.kind != .tombstone, - records.values.lazy.filter({ $0.kind == .tombstone }).count >= capacity, - victim == nil { - runtime.volatileTombstones.removeValue(forKey: messageID) + guard !newIDs.isEmpty else { + return true + } + guard Set(journal.keys).union(newIDs).count <= capacity else { return false } - let tombstone = ReceiptRecord( - kind: .tombstone, - // Retain the accepted path so a crash between the atomic record - // write and payload unlink can finish cleanup after relaunch. - relativePath: records[messageID]?.relativePath, - recordedAt: date - ) - guard persist(tombstone, messageID: messageID, to: directory) else { - runtime.volatileTombstones.removeValue(forKey: messageID) + var newEntries: [String: DeletionJournalEntry] = [:] + for messageID in newIDs { + // Accepted receipts normally supply the exact stored path. The UI + // fallback is required when that receipt aged or was capacity + // evicted while its bubble and payload remain. They may differ + // after a retry selected a suffixed filename, so journal both. + // Never commit a new pathless tombstone: it could retire + // successfully while leaving an untracked payload behind. + let relativePaths = Array(Set([ + records[messageID]?.relativePath, + payloadRelativePaths[messageID] + ].compactMap { $0 })).sorted() + guard !relativePaths.isEmpty, + relativePaths.count <= + Self.maximumDeletionPathsPerMessage, + relativePaths.allSatisfy({ + isSafeDeletionTarget(relativePath: $0) + }), + protectedPayloadRelativePaths.isDisjoint( + with: relativePaths + ), + !records.contains(where: { otherMessageID, record in + otherMessageID != messageID + && !stableIDs.contains(otherMessageID) + && record.relativePath.map( + relativePaths.contains + ) == true + }), + !journal.contains(where: { otherMessageID, entry in + otherMessageID != messageID + && !stableIDs.contains(otherMessageID) + && !Set(entry.relativePaths).isDisjoint( + with: relativePaths + ) + }) else { + return false + } + newEntries[messageID] = DeletionJournalEntry( + // The journal retains every owned path so payload deletion + // remains recoverable across a crash or unlink failure. + relativePaths: relativePaths, + recordedAt: date + ) + } + journal.merge(newEntries) { _, new in new } + + // Do not install any process-local tombstone before this succeeds. + // A failed delete must continue to resolve to its prior accepted + // state, otherwise a retry could be falsely ACKed while UI remains. + guard persistDeletionJournal(journal, in: directory) else { return false } - records[messageID] = tombstone - if let victim, victim != messageID { - records.removeValue(forKey: victim) - removeRecord(messageID: victim, from: directory) - } - runtime.records = records - runtime.volatileTombstones.removeValue(forKey: messageID) - removePayloadRecordedByTombstone(tombstone) + runtime.deletionJournal = journal + _ = recoverDeletionJournal(in: directory) return true } - private func loadIndexIfNeeded( + func recordDeleted(messageID: String) -> Bool { + guard PrivateMediaMessageIdentity.isStableID(messageID) else { + return false + } + return recordDeleted(messageIDs: [messageID]) + } + + /// Resolves every path a deletion transaction may target. The incoming + /// allocator reserves this set at the journal barrier so a concurrent raw + /// arrival cannot reuse a missing UI fallback. + func prospectiveDeletionPayloadPaths( + messageIDs: [String], + payloadRelativePaths: [String: String] + ) -> Set? { + let stableIDs = Set( + messageIDs.filter(PrivateMediaMessageIdentity.isStableID) + ) + guard !stableIDs.isEmpty else { return [] } + + runtime.lock.lock() + defer { runtime.lock.unlock() } + + let date = now() + guard let directory = resolvedReceiptDirectory(), + loadDurableStateIfNeeded(from: directory, at: date) else { + return nil + } + _ = recoverDeletionJournal(in: directory) + guard let journal = runtime.deletionJournal, + let records = runtime.records else { + return nil + } + + var paths: Set = [] + for messageID in stableIDs { + if let entry = journal[messageID] { + paths.formUnion(entry.relativePaths) + continue + } + if let relativePath = records[messageID]?.relativePath { + paths.insert(relativePath) + } + if let relativePath = payloadRelativePaths[messageID] { + paths.insert(relativePath) + } + } + guard paths.allSatisfy({ + candidatePayload(relativePath: $0) != nil + }) else { + return nil + } + return Set(paths.compactMap { + candidatePayload(relativePath: $0)? + .standardizedFileURL.path + }) + } + + /// Paths owned by accepted receipts, legacy pathful tombstones, or the + /// deletion journal. Incoming allocation must not reuse any of them for a + /// different ID. + func reservedPayloadPaths() -> Set? { + runtime.lock.lock() + defer { runtime.lock.unlock() } + + let date = now() + guard let directory = resolvedReceiptDirectory(), + loadDurableStateIfNeeded(from: directory, at: date) else { + return nil + } + _ = recoverDeletionJournal(in: directory) + guard let journal = runtime.deletionJournal, + let records = runtime.records else { + return nil + } + let recordPaths = records.values.compactMap(\.relativePath) + let journalPaths = journal.values.flatMap(\.relativePaths) + return Set((recordPaths + journalPaths).compactMap { relativePath in + candidatePayload(relativePath: relativePath)? + .standardizedFileURL.path + }) + } + + private func scrubLegacyTombstone( + _ tombstone: ReceiptRecord, + messageID: String, + in directory: URL, + records: inout [String: ReceiptRecord] + ) -> Bool { + guard tombstone.kind == .tombstone, + tombstone.relativePath != nil else { + return true + } + // Pre-journal tombstones cannot prove that the current file is still + // the payload they originally described. Older allocators did not + // reserve these paths, so a raw/public arrival may have reused the + // basename. Shed the ambiguous path without unlinking anything. + let pathless = ReceiptRecord( + kind: .tombstone, + relativePath: nil, + recordedAt: tombstone.recordedAt + ) + guard persist( + pathless, + messageID: messageID, + to: directory + ) else { + return false + } + records[messageID] = pathless + runtime.records = records + return true + } + + private func loadDurableStateIfNeeded( from directory: URL, at date: Date - ) -> [String: ReceiptRecord]? { - if let records = runtime.records { - return records + ) -> Bool { + if runtime.records != nil, runtime.deletionJournal != nil { + return true } do { @@ -268,7 +497,7 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable { "❌ Failed to create private-media receipt directory: \(error)", category: .session ) - return nil + return false } let urls: [URL] @@ -287,12 +516,13 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable { "❌ Failed to enumerate private-media receipts: \(error)", category: .session ) - return nil + return false } var records: [String: ReceiptRecord] = [:] + var scannedRecords: [String: ReceiptRecord] = [:] var expired: [String] = [] - var tombstones: [ReceiptRecord] = [] + var tombstones: [(messageID: String, record: ReceiptRecord)] = [] for url in urls { guard url.pathExtension == "json" else { continue } let messageID = url.deletingPathExtension().lastPathComponent @@ -311,7 +541,7 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable { "❌ Failed to read private-media receipt \(messageID.prefix(12))…: \(error)", category: .session ) - return nil + return false } guard isStructurallyValid(record) else { @@ -319,16 +549,21 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable { "❌ Invalid private-media receipt \(messageID.prefix(12))…", category: .session ) - return nil + return false } - if isExpired(record.recordedAt, at: date) { + scannedRecords[messageID] = record + if record.kind == .tombstone { + tombstones.append((messageID, record)) + } + let retainsAcceptedPath = + record.kind == .accepted + && record.relativePath.flatMap(existingPayload) != nil + if isExpired(record.recordedAt, at: date), + !retainsAcceptedPath { expired.append(messageID) continue } records[messageID] = record - if record.kind == .tombstone { - tombstones.append(record) - } } let overflow = overflowVictims(in: records) @@ -336,17 +571,229 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable { records.removeValue(forKey: messageID) } - // Install the index only after every stable-ID record was read and - // validated successfully. Cleanup cannot influence a failed scan. - runtime.records = records + let journal: [String: DeletionJournalEntry] + let journalURL = deletionJournalURL(in: directory) + if fileManager.fileExists(atPath: journalURL.path) { + do { + let data = try dataReader?(journalURL) + ?? Data(contentsOf: journalURL) + let snapshot = try JSONDecoder().decode( + DeletionJournal.self, + from: data + ) + guard snapshot.version == 1, + snapshot.entries.count <= capacity, + snapshot.entries.allSatisfy({ messageID, entry in + PrivateMediaMessageIdentity.isStableID(messageID) + && !entry.relativePaths.isEmpty + && entry.relativePaths.count <= + Self.maximumDeletionPathsPerMessage + && Set(entry.relativePaths).count + == entry.relativePaths.count + && entry.relativePaths.allSatisfy { + candidatePayload(relativePath: $0) != nil + } + }) else { + SecureLogger.error( + "❌ Invalid private-media deletion journal", + category: .session + ) + return false + } + journal = snapshot.entries + } catch { + // The journal is the all-ID commit record. It may never be + // skipped or treated as empty when unreadable. + SecureLogger.error( + "❌ Failed to read private-media deletion journal: \(error)", + category: .session + ) + return false + } + } else { + journal = [:] + } + + var protectedFromPruning: Set = [] + + // Old per-ID tombstones may still carry a payload path from before the + // deletion journal existed. That path is inherently ambiguous because + // old allocators did not reserve it. Convert it to a pathless + // tombstone without unlinking any current file. + for (messageID, tombstone) in tombstones + where journal[messageID] == nil + && tombstone.relativePath != nil { + let pathless = ReceiptRecord( + kind: .tombstone, + relativePath: nil, + recordedAt: tombstone.recordedAt + ) + guard persist( + pathless, + messageID: messageID, + to: directory + ) else { + records[messageID] = tombstone + protectedFromPruning.insert(messageID) + continue + } + scannedRecords[messageID] = pathless + if records[messageID] != nil { + records[messageID] = pathless + } + } for messageID in expired + overflow { - removeRecord(messageID: messageID, from: directory) + guard !protectedFromPruning.contains(messageID) else { continue } + if !removeRecord(messageID: messageID, from: directory), + let record = scannedRecords[messageID] { + records[messageID] = record + } } - for tombstone in tombstones { - removePayloadRecordedByTombstone(tombstone) + + // Install caches only after every per-ID record and the batch journal + // have been read and validated. Failed legacy cleanup remains indexed + // and path-reserved instead of blocking unrelated media. + runtime.records = records + runtime.deletionJournal = journal + return true + } + + /// Idempotently materializes the write-ahead journal. An entry leaves the + /// journal only after its per-ID tombstone is durable and every recorded + /// payload is absent. Any failure keeps the journal authoritative for a + /// later lookup or process restart. + @discardableResult + private func recoverDeletionJournal(in directory: URL) -> Bool { + guard let journal = runtime.deletionJournal, + !journal.isEmpty, + var records = runtime.records else { + return true } - return records + + let preservedMessageIDs = Set(journal.keys) + var remaining = journal + let orderedEntries = journal.sorted { lhs, rhs in + if lhs.value.recordedAt == rhs.value.recordedAt { + return lhs.key < rhs.key + } + return lhs.value.recordedAt < rhs.value.recordedAt + } + + for (messageID, journalEntry) in orderedEntries { + let pathlessTombstone = ReceiptRecord( + kind: .tombstone, + relativePath: nil, + recordedAt: journalEntry.recordedAt + ) + if records[messageID] != pathlessTombstone { + let victim = capacityVictim( + for: .tombstone, + replacing: messageID, + in: records, + preserving: preservedMessageIDs + ) + if records[messageID]?.kind != .tombstone, + records.values.lazy.filter({ + $0.kind == .tombstone + }).count >= capacity, + victim == nil { + continue + } + guard persist( + pathlessTombstone, + messageID: messageID, + to: directory + ) else { + continue + } + records[messageID] = pathlessTombstone + if let victim, victim != messageID { + records.removeValue(forKey: victim) + removeRecord(messageID: victim, from: directory) + } + } + + // Only the journal retains the paths. Once every unlink succeeds, + // the durable per-ID tombstone is pathless and cannot later + // delete a different payload that reused the basename. + let removedEveryPayload = journalEntry.relativePaths.allSatisfy { + removePayloadRecordedByTombstone(ReceiptRecord( + kind: .tombstone, + relativePath: $0, + recordedAt: journalEntry.recordedAt + )) + } + guard removedEveryPayload else { + continue + } + remaining.removeValue(forKey: messageID) + } + + runtime.records = records + guard remaining != journal else { return false } + + if remaining.isEmpty { + guard removeDeletionJournal(in: directory) else { return false } + } else { + guard persistDeletionJournal(remaining, in: directory) else { + return false + } + } + runtime.deletionJournal = remaining + return remaining.isEmpty + } + + private func persistDeletionJournal( + _ entries: [String: DeletionJournalEntry], + in directory: URL + ) -> Bool { + do { + try fileManager.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: nil + ) + let data = try JSONEncoder().encode( + DeletionJournal(version: 1, entries: entries) + ) + var options: Data.WritingOptions = [.atomic] + #if os(iOS) + options.insert( + .completeFileProtectionUntilFirstUserAuthentication + ) + #endif + try dataWriter(data, deletionJournalURL(in: directory), options) + return true + } catch { + SecureLogger.error( + "❌ Failed to persist private-media deletion journal: \(error)", + category: .session + ) + return false + } + } + + private func removeDeletionJournal(in directory: URL) -> Bool { + let url = deletionJournalURL(in: directory) + guard fileManager.fileExists(atPath: url.path) else { return true } + do { + try fileManager.removeItem(at: url) + return true + } catch { + SecureLogger.warning( + "⚠️ Failed to retire private-media deletion journal: \(error)", + category: .session + ) + return false + } + } + + private func deletionJournalURL(in directory: URL) -> URL { + directory.appendingPathComponent( + Self.deletionJournalFileName, + isDirectory: false + ) } private func isStructurallyValid(_ record: ReceiptRecord) -> Bool { @@ -369,10 +816,14 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable { ) -> [String] { var victims: [String] = [] for kind in [ReceiptRecord.Kind.accepted, .tombstone] { - let matching = records.filter { $0.value.kind == kind } - let overflow = matching.count - capacity + let allMatching = records.filter { $0.value.kind == kind } + let overflow = allMatching.count - capacity guard overflow > 0 else { continue } - victims.append(contentsOf: matching.sorted { lhs, rhs in + let eligible = allMatching.filter { _, record in + guard kind == .accepted else { return true } + return record.relativePath.flatMap(existingPayload) == nil + } + victims.append(contentsOf: eligible.sorted { lhs, rhs in if lhs.value.recordedAt == rhs.value.recordedAt { return lhs.key < rhs.key } @@ -389,13 +840,24 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable { private func capacityVictim( for incomingKind: ReceiptRecord.Kind, replacing messageID: String, - in records: [String: ReceiptRecord] + in records: [String: ReceiptRecord], + preserving preservedMessageIDs: Set = [] ) -> String? { guard records[messageID]?.kind != incomingKind else { return nil } + // During a live session an accepted receipt is the only durable owner + // of its filename, even after quota removes the payload. Evicting it + // here could let another ID reuse the path while the old bubble still + // exists. The large capacity therefore acts as admission control. + guard incomingKind != .accepted else { return nil } let matching = records.filter { - $0.key != messageID && $0.value.kind == incomingKind + $0.key != messageID + && !preservedMessageIDs.contains($0.key) + && $0.value.kind == incomingKind } - guard matching.count >= capacity else { return nil } + let currentCount = records.values.lazy.filter { + $0.kind == incomingKind + }.count + guard currentCount >= capacity else { return nil } return matching.min { lhs, rhs in if lhs.value.recordedAt == rhs.value.recordedAt { return lhs.key < rhs.key @@ -421,7 +883,7 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable { options.insert(.completeFileProtectionUntilFirstUserAuthentication) #endif let url = recordURL(messageID: messageID, in: directory) - try data.write(to: url, options: options) + try dataWriter(data, url, options) return true } catch { SecureLogger.error( @@ -432,16 +894,22 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable { } } - private func removeRecord(messageID: String, from directory: URL) { + @discardableResult + private func removeRecord( + messageID: String, + from directory: URL + ) -> Bool { let url = recordURL(messageID: messageID, in: directory) - guard fileManager.fileExists(atPath: url.path) else { return } + guard fileManager.fileExists(atPath: url.path) else { return true } do { try fileManager.removeItem(at: url) + return true } catch { SecureLogger.warning( "⚠️ Failed to prune private-media receipt \(messageID.prefix(12))…: \(error)", category: .session ) + return false } } @@ -451,39 +919,55 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable { .appendingPathExtension("json") } - private func removePayloadRecordedByTombstone(_ record: ReceiptRecord) { + @discardableResult + private func removePayloadRecordedByTombstone( + _ record: ReceiptRecord + ) -> Bool { guard record.kind == .tombstone, let relativePath = record.relativePath, let payload = candidatePayload(relativePath: relativePath), fileManager.fileExists(atPath: payload.path) else { - return + return true + } + guard let values = try? payload.resourceValues( + forKeys: [.isRegularFileKey] + ), + values.isRegularFile == true else { + SecureLogger.warning( + "⚠️ Refusing to remove non-file private-media payload", + category: .session + ) + return false } do { - try fileManager.removeItem(at: payload) + try payloadRemover(payload) + return !fileManager.fileExists(atPath: payload.path) } catch { SecureLogger.warning( "⚠️ Failed to remove explicitly deleted private media: \(error)", category: .session ) + return false } } - private func addVolatileTombstone(_ messageID: String, at date: Date) { - runtime.volatileTombstones[messageID] = date - let overflow = runtime.volatileTombstones.count - capacity - guard overflow > 0 else { return } - let oldest = runtime.volatileTombstones.sorted { - if $0.value == $1.value { return $0.key < $1.key } - return $0.value < $1.value + private func isSafeDeletionTarget(relativePath: String) -> Bool { + guard let payload = candidatePayload(relativePath: relativePath) else { + return false } - for (oldMessageID, _) in oldest.prefix(overflow) { - runtime.volatileTombstones.removeValue(forKey: oldMessageID) + guard fileManager.fileExists(atPath: payload.path) else { + return true } + return (try? payload.resourceValues( + forKeys: [.isRegularFileKey] + ).isRegularFile) == true } private func validExistingPayload(_ url: URL) -> URL? { let standardized = url.standardizedFileURL - guard isInsideFilesDirectory(standardized) else { return nil } + guard isInsideIncomingMediaDirectory(standardized) else { + return nil + } var isDirectory: ObjCBool = false guard fileManager.fileExists( atPath: standardized.path, @@ -520,15 +1004,27 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable { let candidate = filesRoot .appendingPathComponent(relativePath, isDirectory: false) .standardizedFileURL - guard candidate.path.hasPrefix(filesRoot.path + "/") else { return nil } + guard isInsideIncomingMediaDirectory(candidate) else { return nil } return candidate } - private func isInsideFilesDirectory(_ url: URL) -> Bool { + private func isInsideIncomingMediaDirectory(_ url: URL) -> Bool { guard let filesRoot = try? filesDirectory().standardizedFileURL else { return false } - return url.standardizedFileURL.path.hasPrefix(filesRoot.path + "/") + let parentPath = url.standardizedFileURL + .deletingLastPathComponent().path + return [ + "voicenotes/incoming", + "images/incoming", + "files/incoming" + ].contains { relativeDirectory in + filesRoot.appendingPathComponent( + relativeDirectory, + isDirectory: true + ) + .standardizedFileURL.path == parentPath + } } private func resolvedReceiptDirectory() -> URL? { diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 3e8f0b2a..0dd87400 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -2660,6 +2660,18 @@ final class BLEService: NSObject { removeIncomingFile: { [weak self] storedURL in self?.incomingFileStore.removeIncomingFile(at: storedURL) }, + finishIncomingFileDelivery: { [weak self] storedURL in + // Serialize pending-owner release behind deletion barriers. + // If /clear snapshots before this UI insertion, its already + // queued barrier must still observe the path as pending. If + // insertion wins first, the next MainActor snapshot sees the + // new bubble and protects the path explicitly. + self?.messageQueue.async(flags: .barrier) { + self?.incomingFileStore.finishIncomingFileDelivery( + at: storedURL + ) + } + }, isPrivateMediaSenderBlocked: { [weak self] peerID in guard let self else { return false } let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID) @@ -2684,11 +2696,12 @@ final class BLEService: NSObject { } self.sendDeliveryAck(for: messageID, to: peerID) }, - deliverMessage: { [weak self] message, shouldDeliver, completion in + deliverMessage: { [weak self] message, shouldDeliver, completion, finalization in self?.emitTransportEvent( .messageReceived(message), shouldDeliver: shouldDeliver, - completion: completion + completion: completion, + finalization: finalization ) } ) @@ -4384,8 +4397,87 @@ extension BLEService { // No alias rotation or advertising restarts required. } +// MARK: - Private Media Deletion + +extension BLEService: PrivateMediaDeletionPersisting { + @MainActor + func persistDeletedPrivateMedia( + messageIDs: [String], + payloadRelativePaths: [String: String], + protectedPayloadRelativePaths: Set, + completion: @escaping @MainActor (Bool) -> Void + ) { + let fileStore = incomingFileStore + messageQueue.async(flags: .barrier) { + guard let reservation = fileStore + .reservePrivateMediaDeletion( + messageIDs: messageIDs, + payloadRelativePaths: payloadRelativePaths + ) else { + Task { @MainActor in + completion(false) + } + return + } + let persisted = fileStore + .commitPrivateMediaDeletion( + reservation: reservation, + messageIDs: messageIDs, + payloadRelativePaths: payloadRelativePaths, + protectedPayloadRelativePaths: + protectedPayloadRelativePaths + ) + Task { @MainActor in + completion(persisted) + } + } + } +} + // MARK: - Private Helpers +enum TransportEventDeliveryOutcome: Equatable { + /// A synchronous sink inserted the message and revalidation succeeded. + case accepted + /// A supported plain delegate was invoked, but insertion cannot be + /// confirmed synchronously. + case invokedUnconfirmed + /// No sink accepted the event, or receipt revalidation rejected it. + case rejected +} + +enum TransportEventDeliveryGate { + /// Runs finalization exactly once for every attempted main-actor delivery, + /// including pre-insertion rejection, a missing/rejecting sink, and + /// post-insertion revalidation failure. Only a fully accepted delivery + /// runs `completion` (for example, a stable-media ACK). + @MainActor + static func attempt( + shouldDeliver: () -> Bool, + deliver: () -> TransportEventDeliveryOutcome, + completion: () -> Void, + finalization: (TransportEventDeliveryOutcome) -> Void + ) { + var outcome = TransportEventDeliveryOutcome.rejected + defer { finalization(outcome) } + guard shouldDeliver() else { + return + } + switch deliver() { + case .rejected: + return + case .invokedUnconfirmed: + outcome = .invokedUnconfirmed + return + case .accepted: + break + } + guard shouldDeliver() else { return } + outcome = .accepted + completion() + } +} + extension BLEService { /// Notify UI on the MainActor to satisfy Swift concurrency isolation @@ -4409,52 +4501,58 @@ extension BLEService { private func emitTransportEvent( _ event: TransportEvent, shouldDeliver: (() -> Bool)? = nil, - completion: (() -> Void)? = nil + completion: (() -> Void)? = nil, + finalization: ((TransportEventDeliveryOutcome) -> Void)? = nil ) { notifyUI { [weak self] in - guard let self, - shouldDeliver?() ?? true, - self.deliverTransportEvent(event), - // Quota cleanup can race the asynchronous main-actor hop or - // the synchronous ConversationStore upsert. ACK only while - // the exact durable mapping and file still resolve. - shouldDeliver?() ?? true else { - return - } - completion?() + TransportEventDeliveryGate.attempt( + shouldDeliver: { shouldDeliver?() ?? true }, + deliver: { + guard let self else { return .rejected } + return self.deliverTransportEvent(event) + }, + completion: { completion?() }, + finalization: { finalization?($0) } + ) } } @MainActor @discardableResult - private func deliverTransportEvent(_ event: TransportEvent) -> Bool { + private func deliverTransportEvent( + _ event: TransportEvent + ) -> TransportEventDeliveryOutcome { if case .messageReceived(let message) = event { if let synchronousDelegate = eventDelegate as? SynchronousMessageTransportEventDelegate { return synchronousDelegate .didReceiveTransportMessageSynchronously(message) + ? .accepted + : .rejected } if let eventDelegate { eventDelegate.didReceiveTransportEvent(event) - return false + return .invokedUnconfirmed } if let synchronousDelegate = delegate as? SynchronousMessageTransportEventDelegate { return synchronousDelegate .didReceiveTransportMessageSynchronously(message) + ? .accepted + : .rejected } } if let eventDelegate { eventDelegate.didReceiveTransportEvent(event) - return true + return .accepted } else { - guard let delegate else { return false } + guard let delegate else { return .rejected } delegate.receiveTransportEvent(event) if case .messageReceived = event { - return false + return .invokedUnconfirmed } - return true + return .accepted } } diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index 09213001..6515d38a 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -97,6 +97,19 @@ enum PrivateMediaSendPolicy: Equatable { case blockedDowngrade } +/// Receiver-only persistence surface for explicit private-media deletion. +/// Kept separate from `Transport` so sender retry branches can rebase without +/// inheriting or implementing receiver storage concerns. +protocol PrivateMediaDeletionPersisting: AnyObject { + @MainActor + func persistDeletedPrivateMedia( + messageIDs: [String], + payloadRelativePaths: [String: String], + protectedPayloadRelativePaths: Set, + completion: @escaping @MainActor (Bool) -> Void + ) +} + protocol TransportEventDelegate: AnyObject { @MainActor func didReceiveTransportEvent(_ event: TransportEvent) } diff --git a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift index ea209af4..8b8223df 100644 --- a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift +++ b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift @@ -86,6 +86,10 @@ protocol ChatMediaTransferContext: AnyObject { @discardableResult func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool func removeMessage(withID messageID: String, cleanupFile: Bool) + /// Removes a media bubble with direction-scoped cleanup instead of the + /// broad compatibility cleanup path. + func removeUntombstonedMediaMessage(withID messageID: String) + func removeOutgoingMediaMessage(withID messageID: String) func addSystemMessage(_ content: String) /// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`). func notifyUIChanged() @@ -122,6 +126,15 @@ protocol ChatMediaTransferContext: AnyObject { ) func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) func cancelTransfer(_ transferId: String) + /// Receiver-side stable-ID deletion commit. Implementations must invoke + /// completion only after the entire batch is durably tombstoned. + func persistDeletedPrivateMedia( + messageIDs: [String], + completion: @escaping @MainActor (Bool) -> Void + ) + /// Whether any current private-chat copy of this stable ID came from a + /// remote peer and therefore requires a receiver tombstone. + func requiresPrivateMediaTombstone(messageID: String) -> Bool } extension ChatViewModel: ChatMediaTransferContext { @@ -208,6 +221,130 @@ extension ChatViewModel: ChatMediaTransferContext { func cancelTransfer(_ transferId: String) { meshService.cancelTransfer(transferId) } + + func removeUntombstonedMediaMessage(withID messageID: String) { + let message = conversations.conversationsByID.values.lazy + .flatMap(\.messages) + .first { $0.id == messageID } + if let message { + if !isIncomingPrivateMessage(message) { + mediaTransferCoordinator.cleanupOutgoingLocalFile( + forMessage: message + ) + } + // Legacy/raw incoming media has no durable ID-to-file ownership. + // Its basename may already belong to a pending newer arrival, so + // leave the payload for bounded quota cleanup rather than unlink + // an ambiguous path. + } + removeMessage(withID: messageID, cleanupFile: false) + } + + func removeOutgoingMediaMessage(withID messageID: String) { + let message = conversations.conversationsByID.values.lazy + .flatMap(\.messages) + .first { $0.id == messageID } + if let message { + mediaTransferCoordinator.cleanupOutgoingLocalFile( + forMessage: message + ) + } + removeMessage(withID: messageID, cleanupFile: false) + } + + func persistDeletedPrivateMedia( + messageIDs: [String], + completion: @escaping @MainActor (Bool) -> Void + ) { + guard !messageIDs.isEmpty else { + completion(true) + return + } + guard let persistence = + meshService as? any PrivateMediaDeletionPersisting else { + completion(false) + return + } + let requestedIDs = Set(messageIDs) + let incomingPathReferences = Array( + conversations.conversationsByID.values + .lazy + .flatMap(\.messages) + .compactMap { message -> ( + messageID: String, + path: String + )? in + guard self.isIncomingPrivateMessage(message), + let path = self.incomingMediaRelativePath( + for: message + ) else { + return nil + } + return (message.id, path) + } + ) + let ownerIDsByPath = Dictionary( + grouping: incomingPathReferences, + by: { $0.path } + ).mapValues { Set($0.map(\.messageID)) } + let protectedPayloadRelativePaths = Set( + ownerIDsByPath.compactMap { path, ownerIDs in + ownerIDs.isSubset(of: requestedIDs) ? nil : path + } + ) + var payloadRelativePaths: [String: String] = [:] + for reference in incomingPathReferences + where requestedIDs.contains(reference.messageID) + && ownerIDsByPath[reference.path, default: []] + .isSubset(of: requestedIDs) { + payloadRelativePaths[reference.messageID] = reference.path + } + persistence.persistDeletedPrivateMedia( + messageIDs: messageIDs, + payloadRelativePaths: payloadRelativePaths, + protectedPayloadRelativePaths: + protectedPayloadRelativePaths, + completion: completion + ) + } + + func requiresPrivateMediaTombstone(messageID: String) -> Bool { + guard PrivateMediaMessageIdentity.isStableID(messageID) else { + return false + } + return privateChats.values.lazy.flatMap { $0 }.contains { message in + message.id == messageID && isIncomingPrivateMessage(message) + } + } + + private func isIncomingPrivateMessage( + _ message: BitchatMessage + ) -> Bool { + if let senderPeerID = message.senderPeerID { + return senderPeerID.toShort() != meshService.myPeerID.toShort() + } + return message.sender != nickname + && !message.sender.hasPrefix(nickname + "#") + } + + private func incomingMediaRelativePath( + for message: BitchatMessage + ) -> String? { + let categories: [MimeType.Category] = [.audio, .image, .file] + guard let category = categories.first(where: { + message.content.hasPrefix($0.messagePrefix) + }), + let rawFilename = String( + message.content.dropFirst(category.messagePrefix.count) + ).trimmedOrNilIfEmpty, + let safeFilename = + (rawFilename as NSString).lastPathComponent.nilIfEmpty, + safeFilename != ".", + safeFilename != ".." else { + return nil + } + return "\(category.mediaDir)/incoming/\(safeFilename)" + } } /// Synchronous boundary between detached image writers and panic deletion. @@ -871,8 +1008,7 @@ final class ChatMediaTransferCoordinator { cancelActiveTransfer: false ) clearTransferMapping(for: messageID) - context.removeMessage(withID: messageID, cleanupFile: true) - + context.removeOutgoingMediaMessage(withID: messageID) case .rejected(let id, let reason): guard let messageID = currentMessageID(forTransferID: id) else { return @@ -891,6 +1027,40 @@ final class ChatMediaTransferCoordinator { } func cleanupLocalFile(forMessage message: BitchatMessage) { + cleanupLocalFile( + forMessage: message, + directions: ["outgoing", "incoming"], + searchAllCategories: true + ) + } + + /// `/clear` may cancel an outgoing message before receiver tombstones are + /// committed. Restrict cleanup to that message's outgoing directory so a + /// same-name incoming payload cannot be removed prematurely. + func cleanupOutgoingLocalFile(forMessage message: BitchatMessage) { + cleanupLocalFile( + forMessage: message, + directions: ["outgoing"], + searchAllCategories: false + ) + } + + /// Receiver cleanup runs only after any required tombstone commit. Keep it + /// scoped to the parsed media category and incoming directory so unrelated + /// outgoing or cross-category payloads with the same basename survive. + func cleanupIncomingLocalFile(forMessage message: BitchatMessage) { + cleanupLocalFile( + forMessage: message, + directions: ["incoming"], + searchAllCategories: false + ) + } + + private func cleanupLocalFile( + forMessage message: BitchatMessage, + directions: [String], + searchAllCategories: Bool + ) { let categories: [MimeType.Category] = [.audio, .image, .file] guard let category = categories.first(where: { message.content.hasPrefix($0.messagePrefix) }), let rawFilename = String(message.content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty, @@ -901,11 +1071,27 @@ final class ChatMediaTransferCoordinator { return } - let subdirs = categories.flatMap { ["\($0.mediaDir)/outgoing", "\($0.mediaDir)/incoming"] } + let targetCategories = searchAllCategories ? categories : [category] + let subdirs = targetCategories.flatMap { category in + directions.map { "\(category.mediaDir)/\($0)" } + } for subdir in subdirs { let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename) guard target.path.hasPrefix(base.path) else { continue } + guard FileManager.default.fileExists(atPath: target.path) else { + continue + } + guard let values = try? target.resourceValues( + forKeys: [.isRegularFileKey] + ), + values.isRegularFile == true else { + SecureLogger.warning( + "Refusing to cleanup non-file media target \(safeFilename)", + category: .session + ) + continue + } do { try FileManager.default.removeItem(at: target) } catch CocoaError.fileNoSuchFile { @@ -917,33 +1103,79 @@ final class ChatMediaTransferCoordinator { } func cancelMediaSend(messageID: String) { - discardReconnectRetry( - messageID: messageID, - cancelActiveTransfer: false - ) - if let transferId = messageIDToTransferId[messageID], - let active = transferIdToMessageIDs[transferId]?.first, - active == messageID { - context.cancelTransfer(transferId) - } - clearTransferMapping(for: messageID) - context.removeMessage(withID: messageID, cleanupFile: true) + cancelAllMediaSendOwners(messageID: messageID) + context.removeOutgoingMediaMessage(withID: messageID) + } + + /// Lets `/clear` cancel send ownership without implicitly deciding which + /// bubbles/files its deletion transaction may remove. + func cancelMediaTransferForConversationClear(messageID: String) { + cancelAllMediaSendOwners(messageID: messageID) } func deleteMediaMessage(messageID: String) { + // Stop every exact sender owner before the durable receiver commit. + // Otherwise a retained retry or admitted legacy send could transmit + // after the bubble and payload have been deleted. + cancelAllMediaSendOwners(messageID: messageID) + + guard context.requiresPrivateMediaTombstone( + messageID: messageID + ) else { + finishMediaDeletion( + messageID: messageID, + receiverJournalOwnsPayload: false + ) + return + } + + context.persistDeletedPrivateMedia( + messageIDs: [messageID] + ) { [weak self] persisted in + guard let self else { return } + guard persisted else { + SecureLogger.error( + "Refusing to delete private media without a durable tombstone id=\(messageID.prefix(12))…", + category: .session + ) + return + } + self.finishMediaDeletion( + messageID: messageID, + receiverJournalOwnsPayload: true + ) + } + } + + private func finishMediaDeletion( + messageID: String, + receiverJournalOwnsPayload: Bool + ) { + if receiverJournalOwnsPayload { + // The journal already owns the exact path. A basename cleanup here + // could delete a different arrival that reused it after unlink. + context.removeMessage(withID: messageID, cleanupFile: false) + } else { + context.removeUntombstonedMediaMessage(withID: messageID) + } + } + + private func cancelAllMediaSendOwners(messageID: String) { + // This releases the retained packet and expiry/retry owner. When its + // exact active transfer still owns the mapping, it cancels that owner + // before any deletion persistence or UI mutation can proceed. discardReconnectRetry( messageID: messageID, - cancelActiveTransfer: false + cancelActiveTransfer: true ) - // Delete is also a send cancellation. In particular, an approved - // legacy-clear send may still be waiting on BLEService.messageQueue; - // removing only the UI mapping would let that deferred work transmit. + // In particular, an approved legacy send may still be waiting on + // BLEService.messageQueue. Its admission must be canceled before the + // mapping/consent owner is released. if let transferId = messageIDToTransferId[messageID], transferIdToMessageIDs[transferId]?.first == messageID { context.cancelTransfer(transferId) } clearTransferMapping(for: messageID) - context.removeMessage(withID: messageID, cleanupFile: true) } /// A raw link callback can arrive before the replacement Noise session diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index ca30cd52..6f3022d9 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -109,6 +109,16 @@ struct PanicNetworkLifecycle { } } +private struct PendingPrivateChatClear { + let peerID: PeerID + let sourceConversationID: ConversationID + let messages: [BitchatMessage] + let otherMessageIDs: Set + let localPeerID: PeerID + let nickname: String + let outgoingMedia: [BitchatMessage] +} + /// Manages the application state and business logic for BitChat. /// Acts as the primary coordinator between UI components and backend services, /// implementing the BitchatDelegate protocol to handle network events. @@ -376,6 +386,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage @Published var bluetoothAlertMessage = "" @Published var bluetoothState: CBManagerState = .unknown @Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest? + @MainActor private var queuedPrivateChatClears: [ + PendingPrivateChatClear + ] = [] + @MainActor private var privateChatClearInFlight = false private var pendingLegacyPrivateMediaConsents: [PendingLegacyPrivateMediaConsent] = [] private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) { @@ -643,7 +657,255 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage /// Empties the peer's chat but keeps the conversation alive (`/clear`). @MainActor func clearPrivateChat(_ peerID: PeerID) { - conversations.clear(.directPeer(peerID)) + let sourceConversationID = ConversationID.directPeer(peerID) + // An active live-voice row owns an open FileHandle and may be + // republished as frames/final media arrive. Treat it like an in-flight + // arrival rather than unlinking its capture or removing its bubble. + let messages = privateMessages(for: peerID).filter { + !liveVoiceCoordinator.isLiveVoiceMessage($0) + } + let localPeerID = meshService.myPeerID.toShort() + let currentNickname = nickname + let mediaPrefixes = [ + MimeType.Category.audio.messagePrefix, + MimeType.Category.image.messagePrefix, + MimeType.Category.file.messagePrefix + ] + let outgoingMedia = messages.filter { message in + guard mediaPrefixes.contains(where: { + message.content.hasPrefix($0) + }) else { + return false + } + if let senderPeerID = message.senderPeerID { + return senderPeerID.toShort() == localPeerID + } + return message.sender == currentNickname + || message.sender.hasPrefix(currentNickname + "#") + } + + // Send ownership is canceled at command time even when another clear + // transaction is ahead in the queue. UI and files remain untouched + // until this request's receiver journal commit succeeds. + for message in outgoingMedia { + mediaTransferCoordinator + .cancelMediaTransferForConversationClear( + messageID: message.id + ) + } + + queuedPrivateChatClears.append(PendingPrivateChatClear( + peerID: peerID, + sourceConversationID: sourceConversationID, + messages: messages, + otherMessageIDs: Set( + privateChats + .filter { $0.key != peerID } + .flatMap { $0.value.map(\.id) } + ), + localPeerID: localPeerID, + nickname: currentNickname, + outgoingMedia: outgoingMedia + )) + startNextPrivateChatClearIfNeeded() + } + + @MainActor + private func startNextPrivateChatClearIfNeeded() { + guard !privateChatClearInFlight, + !queuedPrivateChatClears.isEmpty else { + return + } + privateChatClearInFlight = true + let request = queuedPrivateChatClears.removeFirst() + performPrivateChatClear(request) { [weak self] in + guard let self else { return } + self.privateChatClearInFlight = false + self.startNextPrivateChatClearIfNeeded() + } + } + + @MainActor + private func performPrivateChatClear( + _ request: PendingPrivateChatClear, + completion: @escaping @MainActor () -> Void + ) { + let peerID = request.peerID + let selectedConversationID = request.sourceConversationID + let messagesToClear = request.messages + guard !messagesToClear.isEmpty else { + completion() + return + } + + // Capture the transaction's exact UI set before any off-main receipt + // I/O. Messages arriving while the journal is written are not part of + // this command and must remain visible. + let capturedMessageIDs = Set(messagesToClear.map(\.id)) + let survivingMessageIDs = request.otherMessageIDs + let mediaPrefixes = [ + MimeType.Category.audio.messagePrefix, + MimeType.Category.image.messagePrefix, + MimeType.Category.file.messagePrefix + ] + let localPeerID = request.localPeerID + let isMedia: (BitchatMessage) -> Bool = { message in + mediaPrefixes.contains(where: message.content.hasPrefix) + } + let isFromMe: (BitchatMessage) -> Bool = { [nickname = request.nickname] message in + if let senderPeerID = message.senderPeerID { + return senderPeerID.toShort() == localPeerID + } + return message.sender == nickname + || message.sender.hasPrefix(nickname + "#") + } + + let outgoingMedia = request.outgoingMedia + let outgoingMediaIDs = Set(outgoingMedia.map(\.id)) + + let capturedExclusiveIDs = + capturedMessageIDs.subtracting(survivingMessageIDs) + let capturedIncomingMedia = messagesToClear.filter { + isMedia($0) && !isFromMe($0) + } + let capturedStableMediaIDs = Set( + capturedIncomingMedia.compactMap { message in + PrivateMediaMessageIdentity.isStableID(message.id) + ? message.id + : nil + } + ) + + func currentRemovalPlan() -> [ConversationID: Set] { + // Identity handoff removes the source conversation and inserts its + // rows elsewhere. The old source may then be recreated by a new + // arrival before journal I/O finishes, so always scan all direct + // conversations. Only IDs exclusive at command time may follow a + // migration; shared aliases remain outside the source. + var plan: [ConversationID: Set] = [:] + for (conversationID, conversation) in + conversations.conversationsByID { + guard case .direct = conversationID else { continue } + let eligibleIDs = conversationID == selectedConversationID + ? capturedMessageIDs + : capturedExclusiveIDs + let matchingIDs = Set(conversation.messages.map(\.id)) + .intersection(eligibleIDs) + if !matchingIDs.isEmpty { + plan[conversationID] = matchingIDs + } + } + return plan + } + + func hasRemainingCopy( + of messageID: String, + after plan: [ConversationID: Set] + ) -> Bool { + conversations.conversationsByID.contains { conversationID, conversation in + guard case .direct = conversationID else { return false } + return conversation.messages.contains { message in + message.id == messageID + && plan[conversationID]?.contains(messageID) != true + } + } + } + + @MainActor + func continueClear( + persisted: Bool, + durableStableIDs: Set + ) { + guard persisted else { + SecureLogger.error( + "Refusing to clear private chat without durable media tombstones peer=\(peerID.id.prefix(8))…", + category: .session + ) + completion() + return + } + + let plan = currentRemovalPlan() + let newlyLastStableIDs = Set( + capturedStableMediaIDs.filter { + !durableStableIDs.contains($0) + && !hasRemainingCopy(of: $0, after: plan) + } + ) + if !newlyLastStableIDs.isEmpty { + persistDeletedPrivateMedia( + messageIDs: Array(newlyLastStableIDs).sorted() + ) { persisted in + continueClear( + persisted: persisted, + durableStableIDs: + durableStableIDs.union(newlyLastStableIDs) + ) + } + return + } + + // A stable receiver tombstone is global for that message ID. + // Remove any alias that arrived while journal I/O was in flight. + if !durableStableIDs.isEmpty { + let directConversationIDs = conversations + .conversationsByID.keys.filter { + if case .direct = $0 { return true } + return false + } + for conversationID in directConversationIDs { + conversations.removeMessages(from: conversationID) { + durableStableIDs.contains($0.id) + } + } + } + + for message in outgoingMedia { + mediaTransferCoordinator.cleanupOutgoingLocalFile( + forMessage: message + ) + } + if !outgoingMediaIDs.isEmpty { + let directConversationIDs = conversations + .conversationsByID.keys.filter { + if case .direct = $0 { return true } + return false + } + for conversationID in directConversationIDs { + conversations.removeMessages(from: conversationID) { + outgoingMediaIDs.contains($0.id) + } + } + } + + // Stable payload cleanup belongs entirely to the durable receiver + // journal. Legacy/raw incoming payloads have no durable identity, + // so their basenames may already belong to a pending new arrival; + // leave those files for bounded quota cleanup. + let finalPlan = currentRemovalPlan() + + for (conversationID, messageIDs) in finalPlan { + conversations.removeMessages(from: conversationID) { + messageIDs.contains($0.id) + } + } + completion() + } + + let initialPlan = currentRemovalPlan() + let initialStableIDs = Set( + capturedStableMediaIDs.filter { + !hasRemainingCopy(of: $0, after: initialPlan) + } + ) + persistDeletedPrivateMedia( + messageIDs: Array(initialStableIDs).sorted() + ) { persisted in + continueClear( + persisted: persisted, + durableStableIDs: initialStableIDs + ) + } } /// Removes the peer's chat entirely, including unread state. diff --git a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift index f0b88dd0..4ebc5e41 100644 --- a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift +++ b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift @@ -58,6 +58,8 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext { private(set) var appendedPublicMessages: [(message: BitchatMessage, conversationID: ConversationID)] = [] private(set) var removedMessages: [(messageID: String, cleanupFile: Bool)] = [] + private(set) var untombstonedMediaRemovals: [String] = [] + private(set) var outgoingMediaRemovals: [String] = [] private(set) var systemMessages: [String] = [] private(set) var notifyUIChangedCount = 0 @@ -71,6 +73,16 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext { removedMessages.append((messageID, cleanupFile)) } + func removeUntombstonedMediaMessage(withID messageID: String) { + untombstonedMediaRemovals.append(messageID) + removedMessages.append((messageID, false)) + } + + func removeOutgoingMediaMessage(withID messageID: String) { + outgoingMediaRemovals.append(messageID) + removedMessages.append((messageID, false)) + } + func addSystemMessage(_ content: String) { systemMessages.append(content) } func notifyUIChanged() { notifyUIChangedCount += 1 } @@ -99,6 +111,13 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext { private(set) var broadcastFileSends: [String] = [] private(set) var cancelledTransfers: [String] = [] private(set) var privateMediaPolicyResolutionRequests: [PeerID] = [] + private(set) var persistedDeletionBatches: [[String]] = [] + var requiredTombstoneIDs: Set = [] + var deletedMediaPersistenceResult = true + var deferDeletedMediaPersistence = false + private var pendingDeletionCompletions: [ + @MainActor (Bool) -> Void + ] = [] var privateMediaPolicy: PrivateMediaSendPolicy = .encrypted var resolvedPrivateMediaPolicy: PrivateMediaSendPolicy? var resolvesPrivateMediaPolicyImmediately = true @@ -218,6 +237,28 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext { func cancelTransfer(_ transferId: String) { cancelledTransfers.append(transferId) } + + func persistDeletedPrivateMedia( + messageIDs: [String], + completion: @escaping @MainActor (Bool) -> Void + ) { + persistedDeletionBatches.append(messageIDs) + if deferDeletedMediaPersistence { + pendingDeletionCompletions.append(completion) + } else { + completion(deletedMediaPersistenceResult) + } + } + + func requiresPrivateMediaTombstone(messageID: String) -> Bool { + requiredTombstoneIDs.contains(messageID) + } + + func resolveNextDeletionPersistence(_ result: Bool? = nil) { + guard !pendingDeletionCompletions.isEmpty else { return } + let completion = pendingDeletionCompletions.removeFirst() + completion(result ?? deletedMediaPersistenceResult) + } } private final class PausedVoiceNotePreparer: @unchecked Sendable { @@ -381,7 +422,8 @@ struct ChatMediaTransferCoordinatorContextTests { coordinator.handleTransferEvent(.cancelled(id: "t2", sentFragments: 1, totalFragments: 5)) #expect(context.removedMessages.count == 1) #expect(context.removedMessages.first?.messageID == "m2") - #expect(context.removedMessages.first?.cleanupFile == true) + #expect(context.removedMessages.first?.cleanupFile == false) + #expect(context.outgoingMediaRemovals == ["m2"]) // A pre-start rejection keeps the placeholder visible and failed, // including queued post-handshake encryption failures. @@ -531,7 +573,154 @@ struct ChatMediaTransferCoordinatorContextTests { #expect(context.cancelledTransfers == ["approved-delete"]) #expect(coordinator.messageIDToTransferId["message-delete"] == nil) #expect(context.removedMessages.map(\.messageID) == ["message-delete"]) - #expect(context.removedMessages.first?.cleanupFile == true) + #expect(context.removedMessages.first?.cleanupFile == false) + #expect( + context.untombstonedMediaRemovals == ["message-delete"] + ) + } + + @Test @MainActor + func deleteIncomingStableMediaWaitsForDurableTombstone() { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + let messageID = "media-00112233445566778899aabbccddeeff" + context.requiredTombstoneIDs = [messageID] + context.deferDeletedMediaPersistence = true + coordinator.registerTransfer( + transferId: "incoming-delete", + messageID: messageID + ) + + coordinator.deleteMediaMessage(messageID: messageID) + + #expect(context.persistedDeletionBatches == [[messageID]]) + #expect(context.removedMessages.isEmpty) + #expect(context.cancelledTransfers == ["incoming-delete"]) + #expect(coordinator.messageIDToTransferId[messageID] == nil) + + context.resolveNextDeletionPersistence(true) + + #expect(context.cancelledTransfers == ["incoming-delete"]) + #expect(context.removedMessages.map(\.messageID) == [messageID]) + #expect(context.removedMessages.first?.cleanupFile == false) + #expect(context.untombstonedMediaRemovals.isEmpty) + #expect(coordinator.messageIDToTransferId[messageID] == nil) + } + + @Test @MainActor + func deleteIncomingStableMediaPreservesStateWhenTombstoneFails() { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + let messageID = "media-ffeeddccbbaa99887766554433221100" + context.requiredTombstoneIDs = [messageID] + context.deletedMediaPersistenceResult = false + coordinator.registerTransfer( + transferId: "failed-delete", + messageID: messageID + ) + + coordinator.deleteMediaMessage(messageID: messageID) + + #expect(context.persistedDeletionBatches == [[messageID]]) + #expect(context.removedMessages.isEmpty) + #expect(context.cancelledTransfers == ["failed-delete"]) + #expect(coordinator.messageIDToTransferId[messageID] == nil) + } + + @Test @MainActor + func deleteStableMediaReleasesRetainedRetryBeforeTombstoneCommit() + async throws + { + let context = MockChatMediaTransferContext() + let peerID = PeerID(str: "1122334455667788") + context.selectedPrivateChatPeer = peerID + context.supportsAuthenticatedPrivateMediaReceipts = true + context.deferDeletedMediaPersistence = true + let fileName = "voice_deadbeefdeadbeef.m4a" + let preparer = StaticVoiceNotePreparer(fileName: fileName) + let coordinator = ChatMediaTransferCoordinator( + context: context, + prepareVoiceNotePacket: { url in + try preparer.prepare(url) + } + ) + let url = try makeCoordinatorVoiceURL(fileName: fileName) + defer { + try? FileManager.default.removeItem( + at: url.deletingLastPathComponent() + ) + } + + coordinator.sendVoiceNote(at: url) + #expect(await TestHelpers.waitUntil( + { context.privateFileSends.count == 1 }, + timeout: TestConstants.longTimeout + )) + let messageID = try #require( + context.privateChats[peerID]?.first?.id + ) + let transferID = try #require( + context.privateFileSends.first?.transferId + ) + context.requiredTombstoneIDs = [messageID] + #expect(coordinator.retainedReconnectRetryCount == 1) + + coordinator.deleteMediaMessage(messageID: messageID) + + #expect(context.persistedDeletionBatches == [[messageID]]) + #expect(coordinator.retainedReconnectRetryCount == 0) + #expect(coordinator.retainedReconnectRetryBytes == 0) + #expect(context.cancelledTransfers == [transferID]) + #expect(context.removedMessages.isEmpty) + + coordinator.peerDidReconnect(peerID) + #expect(context.privateFileSends.count == 1) + + context.resolveNextDeletionPersistence(true) + #expect(context.removedMessages.map(\.messageID) == [messageID]) + } + + @Test @MainActor + func legacyCleanupNeverRecursivelyDeletesDirectoryTarget() throws { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + let incoming = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + .appendingPathComponent( + "files/images/incoming", + isDirectory: true + ) + let directoryName = "cleanup-dir-\(UUID().uuidString)" + let directory = incoming.appendingPathComponent( + directoryName, + isDirectory: true + ) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + let child = directory.appendingPathComponent("child.jpg") + try Data([0x01]).write(to: child) + defer { try? FileManager.default.removeItem(at: directory) } + let message = BitchatMessage( + id: UUID().uuidString, + sender: "Peer", + content: + "\(MimeType.Category.image.messagePrefix)\(directoryName)", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Me" + ) + + coordinator.cleanupIncomingLocalFile(forMessage: message) + + #expect(FileManager.default.fileExists(atPath: directory.path)) + #expect(FileManager.default.fileExists(atPath: child.path)) } @Test @MainActor diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index d78b9da7..35fce3d3 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -1194,6 +1194,697 @@ struct ChatViewModelBluetoothTests { } } +// MARK: - Private Media Deletion Tests + +struct ChatViewModelPrivateMediaDeletionTests { + + @Test @MainActor + func deleteMediaMessageTombstonesIncomingButNotOutgoingStableMedia() { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: String(repeating: "8", count: 64)) + let incomingID = "media-\(String(repeating: "e", count: 32))" + let outgoingID = "media-\(String(repeating: "f", count: 32))" + viewModel.seedPrivateChat([ + privateMediaMessage( + id: incomingID, + sender: "Peer", + senderPeerID: peerID, + recipient: viewModel.nickname, + filename: "incoming.jpg" + ), + privateMediaMessage( + id: outgoingID, + sender: viewModel.nickname, + senderPeerID: transport.myPeerID, + recipient: "Peer", + filename: "outgoing.jpg" + ) + ], for: peerID) + + viewModel.deleteMediaMessage(messageID: outgoingID) + #expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty) + #expect(viewModel.privateChats[peerID]?.map(\.id) == [incomingID]) + + viewModel.deleteMediaMessage(messageID: incomingID) + #expect( + transport.deletedPrivateMediaMessageIDBatches == [[incomingID]] + ) + #expect( + transport.deletedPrivateMediaRelativePaths + == [[incomingID: "images/incoming/incoming.jpg"]] + ) + #expect((viewModel.privateChats[peerID] ?? []).isEmpty) + } + + @Test @MainActor + func stableDeleteProtectsPathSharedWithLegacyBubble() { + let (viewModel, transport) = makeTestableViewModel() + transport.persistDeletedPrivateMediaResult = false + let peerID = PeerID(str: String(repeating: "6", count: 64)) + let stableID = "media-\(String(repeating: "5", count: 32))" + let legacyID = UUID().uuidString + let filename = "shared-migration.jpg" + viewModel.seedPrivateChat([ + privateMediaMessage( + id: stableID, + sender: "Peer", + senderPeerID: peerID, + recipient: viewModel.nickname, + filename: filename + ), + privateMediaMessage( + id: legacyID, + sender: "Old client", + senderPeerID: peerID, + recipient: viewModel.nickname, + filename: filename + ) + ], for: peerID) + + viewModel.deleteMediaMessage(messageID: stableID) + + #expect( + transport.deletedPrivateMediaMessageIDBatches == [[stableID]] + ) + #expect(transport.deletedPrivateMediaRelativePaths == [[:]]) + #expect( + transport.protectedPrivateMediaRelativePaths == [[ + "images/incoming/\(filename)" + ]] + ) + #expect( + viewModel.privateChats[peerID]?.map(\.id) + == [stableID, legacyID] + ) + } + + @Test @MainActor + func legacyIncomingDeleteLeavesAmbiguousPayloadForQuota() throws { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: String(repeating: "3", count: 64)) + let incoming = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + .appendingPathComponent( + "files/images/incoming", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: incoming, + withIntermediateDirectories: true + ) + let payload = incoming.appendingPathComponent( + "legacy-delete-\(UUID().uuidString).jpg" + ) + try Data([0x01]).write(to: payload) + defer { try? FileManager.default.removeItem(at: payload) } + let legacyID = UUID().uuidString + viewModel.seedPrivateChat([ + privateMediaMessage( + id: legacyID, + sender: "Old client", + senderPeerID: peerID, + recipient: viewModel.nickname, + filename: payload.lastPathComponent + ) + ], for: peerID) + + viewModel.deleteMediaMessage(messageID: legacyID) + + #expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty) + #expect((viewModel.privateChats[peerID] ?? []).isEmpty) + #expect(FileManager.default.fileExists(atPath: payload.path)) + } + + @Test @MainActor + func clearPrivateChatTombstonesIncomingAndCancelsOutgoingBeforeClear() { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: String(repeating: "1", count: 64)) + let incomingID = "media-\(String(repeating: "a", count: 32))" + let outgoingID = "media-\(String(repeating: "b", count: 32))" + viewModel.seedPrivateChat([ + privateMediaMessage( + id: incomingID, + sender: "Peer", + senderPeerID: peerID, + recipient: viewModel.nickname, + filename: "incoming.jpg" + ), + privateMediaMessage( + id: outgoingID, + sender: viewModel.nickname, + senderPeerID: transport.myPeerID, + recipient: "Peer", + filename: "outgoing.jpg" + ), + BitchatMessage( + id: "ordinary-message", + sender: "Peer", + content: "hello", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: viewModel.nickname, + senderPeerID: peerID + ) + ], for: peerID) + viewModel.registerTransfer( + transferId: "outgoing-clear-transfer", + messageID: outgoingID + ) + + viewModel.clearPrivateChat(peerID) + + #expect( + transport.deletedPrivateMediaMessageIDBatches == [[incomingID]] + ) + #expect( + transport.deletedPrivateMediaRelativePaths + == [[incomingID: "images/incoming/incoming.jpg"]] + ) + #expect( + transport.cancelledTransfers == ["outgoing-clear-transfer"] + ) + #expect(viewModel.messageIDToTransferId[outgoingID] == nil) + #expect(viewModel.privateChats[peerID]?.isEmpty == true) + } + + @Test @MainActor + func clearPrivateChatPreservesCapturedMessagesWhenTombstoneFails() { + let (viewModel, transport) = makeTestableViewModel() + transport.persistDeletedPrivateMediaResult = false + let peerID = PeerID(str: String(repeating: "2", count: 64)) + let incomingID = "media-\(String(repeating: "c", count: 32))" + let outgoingID = "media-\(String(repeating: "7", count: 32))" + viewModel.seedPrivateChat([ + privateMediaMessage( + id: incomingID, + sender: "Peer", + senderPeerID: peerID, + recipient: viewModel.nickname, + filename: "incoming.jpg" + ), + privateMediaMessage( + id: outgoingID, + sender: viewModel.nickname, + senderPeerID: transport.myPeerID, + recipient: "Peer", + filename: "outgoing.jpg" + ), + BitchatMessage( + id: "ordinary-message", + sender: "Peer", + content: "keep me on failure", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: viewModel.nickname, + senderPeerID: peerID + ) + ], for: peerID) + viewModel.registerTransfer( + transferId: "failed-clear-outgoing", + messageID: outgoingID + ) + + viewModel.clearPrivateChat(peerID) + + #expect( + transport.deletedPrivateMediaMessageIDBatches == [[incomingID]] + ) + #expect( + viewModel.privateChats[peerID]?.map(\.id) + == [incomingID, outgoingID, "ordinary-message"] + ) + #expect(transport.cancelledTransfers == ["failed-clear-outgoing"]) + #expect(viewModel.messageIDToTransferId[outgoingID] == nil) + } + + @Test @MainActor + func clearFailurePreservesSameNameIncomingPayload() throws { + let (viewModel, transport) = makeTestableViewModel() + transport.persistDeletedPrivateMediaResult = false + let peerID = PeerID(str: String(repeating: "7", count: 64)) + let incomingID = "media-\(String(repeating: "8", count: 32))" + let outgoingID = "media-\(String(repeating: "9", count: 32))" + let filename = "clear-collision-\(UUID().uuidString).jpg" + let filesDirectory = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + .appendingPathComponent("files/images", isDirectory: true) + let incomingDirectory = filesDirectory.appendingPathComponent( + "incoming", + isDirectory: true + ) + let outgoingDirectory = filesDirectory.appendingPathComponent( + "outgoing", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: incomingDirectory, + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: outgoingDirectory, + withIntermediateDirectories: true + ) + let incomingURL = incomingDirectory.appendingPathComponent(filename) + let outgoingURL = outgoingDirectory.appendingPathComponent(filename) + try Data("incoming".utf8).write(to: incomingURL, options: .atomic) + try Data("outgoing".utf8).write(to: outgoingURL, options: .atomic) + defer { + try? FileManager.default.removeItem(at: incomingURL) + try? FileManager.default.removeItem(at: outgoingURL) + } + viewModel.seedPrivateChat([ + privateMediaMessage( + id: incomingID, + sender: "Peer", + senderPeerID: peerID, + recipient: viewModel.nickname, + filename: filename + ), + privateMediaMessage( + id: outgoingID, + sender: viewModel.nickname, + senderPeerID: transport.myPeerID, + recipient: "Peer", + filename: filename + ) + ], for: peerID) + + viewModel.clearPrivateChat(peerID) + + #expect(FileManager.default.fileExists(atPath: incomingURL.path)) + #expect(FileManager.default.fileExists(atPath: outgoingURL.path)) + #expect( + transport.deletedPrivateMediaRelativePaths + == [[incomingID: "images/incoming/\(filename)"]] + ) + #expect( + viewModel.privateChats[peerID]?.map(\.id) + == [incomingID, outgoingID] + ) + } + + @Test @MainActor + func clearPrivateChatPreservesArrivalDuringTombstoneIO() { + let (viewModel, transport) = makeTestableViewModel() + transport.deferDeletedPrivateMediaPersistence = true + let peerID = PeerID(str: String(repeating: "3", count: 64)) + let incomingID = "media-\(String(repeating: "d", count: 32))" + viewModel.seedPrivateChat([ + privateMediaMessage( + id: incomingID, + sender: "Peer", + senderPeerID: peerID, + recipient: viewModel.nickname, + filename: "incoming.jpg" + ), + BitchatMessage( + id: "captured-text", + sender: "Peer", + content: "old", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: viewModel.nickname, + senderPeerID: peerID + ) + ], for: peerID) + + viewModel.clearPrivateChat(peerID) + let arrival = BitchatMessage( + id: "concurrent-arrival", + sender: "Peer", + content: "new", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: viewModel.nickname, + senderPeerID: peerID + ) + #expect(viewModel.appendPrivateMessage(arrival, to: peerID)) + + transport.resolveNextDeletedPrivateMediaPersistence(true) + + #expect( + viewModel.privateChats[peerID]?.map(\.id) + == ["concurrent-arrival"] + ) + } + + @Test @MainActor + func clearPrivateChatPreservesActiveLiveVoiceAssembly() throws { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: String(repeating: "7", count: 64)) + viewModel.selectedPrivateChatPeer = peerID + let burstID = Data( + repeating: 0xE1, + count: VoiceBurstPacket.burstIDSize + ) + let start = try #require(VoiceBurstPacket( + burstID: burstID, + seq: 0, + kind: .start(codec: .aacLC16kMono) + )) + let cancel = try #require(VoiceBurstPacket( + burstID: burstID, + seq: 1, + kind: .canceled + )) + let coordinator = viewModel.liveVoiceCoordinator + defer { + coordinator.handleVoiceFramePayload( + from: peerID, + payload: cancel.encode(), + timestamp: Date() + ) + } + coordinator.handleVoiceFramePayload( + from: peerID, + payload: start.encode(), + timestamp: Date() + ) + let liveMessage = try #require( + viewModel.privateChats[peerID]?.first + ) + #expect(coordinator.isLiveVoiceMessage(liveMessage)) + let ordinary = BitchatMessage( + id: "clear-around-live-voice", + sender: "Peer", + content: "old text", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: viewModel.nickname, + senderPeerID: peerID + ) + #expect(viewModel.appendPrivateMessage(ordinary, to: peerID)) + + viewModel.clearPrivateChat(peerID) + + #expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty) + #expect( + viewModel.privateChats[peerID]?.map(\.id) + == [liveMessage.id] + ) + #expect(coordinator.isLiveVoiceMessage(liveMessage)) + } + + @Test @MainActor + func overlappingClearsTombstoneTheLastMirroredStableAlias() { + let (viewModel, transport) = makeTestableViewModel() + transport.deferDeletedPrivateMediaPersistence = true + let firstPeerID = PeerID(str: String(repeating: "b", count: 64)) + let secondPeerID = PeerID(str: String(repeating: "c", count: 64)) + let sharedID = "media-\(String(repeating: "1", count: 32))" + let firstUniqueID = "media-\(String(repeating: "2", count: 32))" + let secondUniqueID = "media-\(String(repeating: "3", count: 32))" + viewModel.seedPrivateChat([ + privateMediaMessage( + id: sharedID, + sender: "Peer", + senderPeerID: firstPeerID, + recipient: viewModel.nickname, + filename: "shared.jpg" + ), + privateMediaMessage( + id: firstUniqueID, + sender: "Peer", + senderPeerID: firstPeerID, + recipient: viewModel.nickname, + filename: "first.jpg" + ) + ], for: firstPeerID) + viewModel.seedPrivateChat([ + privateMediaMessage( + id: sharedID, + sender: "Peer", + senderPeerID: secondPeerID, + recipient: viewModel.nickname, + filename: "shared.jpg" + ), + privateMediaMessage( + id: secondUniqueID, + sender: "Peer", + senderPeerID: secondPeerID, + recipient: viewModel.nickname, + filename: "second.jpg" + ) + ], for: secondPeerID) + + viewModel.clearPrivateChat(firstPeerID) + viewModel.clearPrivateChat(secondPeerID) + let queuedArrival = BitchatMessage( + id: "arrival-after-queued-clear", + sender: "Peer", + content: "new", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: viewModel.nickname, + senderPeerID: secondPeerID + ) + #expect(viewModel.appendPrivateMessage( + queuedArrival, + to: secondPeerID + )) + + #expect( + transport.deletedPrivateMediaMessageIDBatches + == [[firstUniqueID]] + ) + + transport.resolveNextDeletedPrivateMediaPersistence(true) + + #expect( + transport.deletedPrivateMediaMessageIDBatches == [ + [firstUniqueID], + [secondUniqueID, sharedID].sorted() + ] + ) + + transport.resolveNextDeletedPrivateMediaPersistence(true) + + #expect((viewModel.privateChats[firstPeerID] ?? []).isEmpty) + #expect( + viewModel.privateChats[secondPeerID]?.map(\.id) + == ["arrival-after-queued-clear"] + ) + } + + @Test @MainActor + func clearFollowsCapturedRowsAcrossPeerIdentityMigration() { + let (viewModel, transport) = makeTestableViewModel() + transport.deferDeletedPrivateMediaPersistence = true + let sourcePeerID = PeerID(str: String(repeating: "d", count: 64)) + let destinationPeerID = PeerID(str: String(repeating: "e", count: 64)) + let thirdPeerID = PeerID(str: String(repeating: "f", count: 64)) + let stableID = "media-\(String(repeating: "4", count: 32))" + viewModel.selectedPrivateChatPeer = sourcePeerID + viewModel.seedPrivateChat([ + privateMediaMessage( + id: stableID, + sender: "Peer", + senderPeerID: sourcePeerID, + recipient: viewModel.nickname, + filename: "migrated.jpg" + ), + BitchatMessage( + id: "captured-before-migration", + sender: "Peer", + content: "old", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: viewModel.nickname, + senderPeerID: sourcePeerID + ) + ], for: sourcePeerID) + + viewModel.clearPrivateChat(sourcePeerID) + viewModel.migratePrivateChat( + from: sourcePeerID, + to: destinationPeerID + ) + viewModel.selectedPrivateChatPeer = thirdPeerID + let arrival = BitchatMessage( + id: "arrival-after-migration", + sender: "Peer", + content: "new", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: viewModel.nickname, + senderPeerID: destinationPeerID + ) + #expect(viewModel.appendPrivateMessage( + arrival, + to: destinationPeerID + )) + + transport.resolveNextDeletedPrivateMediaPersistence(true) + + #expect( + viewModel.privateChats[destinationPeerID]?.map(\.id) + == ["arrival-after-migration"] + ) + } + + @Test @MainActor + func clearFollowsMigrationWhenOldSourceIsRecreated() { + let (viewModel, transport) = makeTestableViewModel() + transport.deferDeletedPrivateMediaPersistence = true + let sourcePeerID = PeerID(str: String(repeating: "1", count: 64)) + let destinationPeerID = PeerID(str: String(repeating: "2", count: 64)) + let stableID = "media-\(String(repeating: "6", count: 32))" + viewModel.seedPrivateChat([ + privateMediaMessage( + id: stableID, + sender: "Peer", + senderPeerID: sourcePeerID, + recipient: viewModel.nickname, + filename: "migrated-recreated.jpg" + ), + BitchatMessage( + id: "captured-before-recreation", + sender: "Peer", + content: "old", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: viewModel.nickname, + senderPeerID: sourcePeerID + ) + ], for: sourcePeerID) + + viewModel.clearPrivateChat(sourcePeerID) + viewModel.migratePrivateChat( + from: sourcePeerID, + to: destinationPeerID + ) + let recreatedArrival = BitchatMessage( + id: "arrival-recreating-source", + sender: "Peer", + content: "new", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: viewModel.nickname, + senderPeerID: sourcePeerID + ) + #expect(viewModel.appendPrivateMessage( + recreatedArrival, + to: sourcePeerID + )) + + transport.resolveNextDeletedPrivateMediaPersistence(true) + + #expect( + (viewModel.privateChats[destinationPeerID] ?? []).isEmpty + ) + #expect( + viewModel.privateChats[sourcePeerID]?.map(\.id) + == ["arrival-recreating-source"] + ) + } + + @Test @MainActor + func clearPrivateChatKeepsMediaReferencedByAnotherConversation() { + let (viewModel, transport) = makeTestableViewModel() + let firstPeerID = PeerID(str: String(repeating: "4", count: 64)) + let aliasPeerID = PeerID(str: String(repeating: "5", count: 64)) + let messageID = "media-\(String(repeating: "6", count: 32))" + let message = privateMediaMessage( + id: messageID, + sender: "Peer", + senderPeerID: firstPeerID, + recipient: viewModel.nickname, + filename: "mirrored.jpg" + ) + viewModel.seedPrivateChat([message], for: firstPeerID) + viewModel.seedPrivateChat([message], for: aliasPeerID) + + viewModel.clearPrivateChat(firstPeerID) + + #expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty) + #expect(viewModel.privateChats[firstPeerID]?.isEmpty == true) + #expect( + viewModel.privateChats[aliasPeerID]?.map(\.id) == [messageID] + ) + } + + @Test @MainActor + func clearPrivateChatLeavesAmbiguousLegacyFileForQuotaCleanup() + throws { + let (viewModel, transport) = makeTestableViewModel() + let firstPeerID = PeerID(str: String(repeating: "9", count: 64)) + let aliasPeerID = PeerID(str: String(repeating: "a", count: 64)) + let incomingDirectory = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + .appendingPathComponent("files/images/incoming", isDirectory: true) + try FileManager.default.createDirectory( + at: incomingDirectory, + withIntermediateDirectories: true + ) + let fileURL = incomingDirectory.appendingPathComponent( + "legacy-clear-\(UUID().uuidString).jpg" + ) + try Data("legacy-image".utf8).write(to: fileURL, options: .atomic) + defer { try? FileManager.default.removeItem(at: fileURL) } + let message = privateMediaMessage( + id: UUID().uuidString, + sender: "Old client", + senderPeerID: firstPeerID, + recipient: viewModel.nickname, + filename: fileURL.lastPathComponent + ) + viewModel.seedPrivateChat([message], for: firstPeerID) + viewModel.seedPrivateChat([message], for: aliasPeerID) + + viewModel.clearPrivateChat(firstPeerID) + + #expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty) + #expect(FileManager.default.fileExists(atPath: fileURL.path)) + #expect(viewModel.privateChats[aliasPeerID]?.map(\.id) == [message.id]) + + viewModel.clearPrivateChat(aliasPeerID) + + #expect(FileManager.default.fileExists(atPath: fileURL.path)) + #expect((viewModel.privateChats[aliasPeerID] ?? []).isEmpty) + } + + private func privateMediaMessage( + id: String, + sender: String, + senderPeerID: PeerID, + recipient: String, + filename: String + ) -> BitchatMessage { + BitchatMessage( + id: id, + sender: sender, + content: "\(MimeType.Category.image.messagePrefix)\(filename)", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: recipient, + senderPeerID: senderPeerID + ) + } +} + // MARK: - Panic Clear Tests struct ChatViewModelPanicTests { diff --git a/bitchatTests/Mocks/MockTransport.swift b/bitchatTests/Mocks/MockTransport.swift index b383c760..2efe9f15 100644 --- a/bitchatTests/Mocks/MockTransport.swift +++ b/bitchatTests/Mocks/MockTransport.swift @@ -14,7 +14,7 @@ import BitFoundation /// Mock Transport implementation for testing ChatViewModel in isolation. /// Records all method calls and allows test code to verify interactions. -final class MockTransport: Transport { +final class MockTransport: Transport, PrivateMediaDeletionPersisting { // MARK: - Protocol Properties @@ -38,6 +38,11 @@ final class MockTransport: Transport { private(set) var sentPrivateFiles: [(packet: BitchatFilePacket, peerID: PeerID, transferID: String)] = [] private(set) var sentPrivateFileLegacyAllowances: [Bool] = [] private(set) var cancelledTransfers: [String] = [] + private(set) var deletedPrivateMediaMessageIDBatches: [[String]] = [] + private(set) var deletedPrivateMediaRelativePaths: [ + [String: String] + ] = [] + private(set) var protectedPrivateMediaRelativePaths: [Set] = [] private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] private(set) var sentCourierMessages: [(content: String, messageID: String, recipientNoiseKey: Data, couriers: [PeerID])] = [] @@ -60,6 +65,11 @@ final class MockTransport: Transport { var peerFingerprints: [PeerID: String] = [:] var peerNoiseStates: [PeerID: LazyHandshakeState] = [:] var privateMediaPolicies: [PeerID: PrivateMediaSendPolicy] = [:] + var persistDeletedPrivateMediaResult = true + var deferDeletedPrivateMediaPersistence = false + private var pendingDeletedPrivateMediaCompletions: [ + @MainActor (Bool) -> Void + ] = [] private let mockKeychain = MockKeychain() // MARK: - Transport Protocol Implementation @@ -217,6 +227,34 @@ final class MockTransport: Transport { cancelledTransfers.append(transferId) } + @MainActor + func persistDeletedPrivateMedia( + messageIDs: [String], + payloadRelativePaths: [String: String], + protectedPayloadRelativePaths: Set, + completion: @escaping @MainActor (Bool) -> Void + ) { + deletedPrivateMediaMessageIDBatches.append(messageIDs) + deletedPrivateMediaRelativePaths.append(payloadRelativePaths) + protectedPrivateMediaRelativePaths.append( + protectedPayloadRelativePaths + ) + if deferDeletedPrivateMediaPersistence { + pendingDeletedPrivateMediaCompletions.append(completion) + } else { + completion(persistDeletedPrivateMediaResult) + } + } + + @MainActor + func resolveNextDeletedPrivateMediaPersistence( + _ result: Bool? = nil + ) { + guard !pendingDeletedPrivateMediaCompletions.isEmpty else { return } + let completion = pendingDeletedPrivateMediaCompletions.removeFirst() + completion(result ?? persistDeletedPrivateMediaResult) + } + func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { sentVerifyChallenges.append((peerID, noiseKeyHex, nonceA)) } @@ -264,6 +302,9 @@ final class MockTransport: Transport { sentBroadcastFiles.removeAll() sentPrivateFiles.removeAll() cancelledTransfers.removeAll() + deletedPrivateMediaMessageIDBatches.removeAll() + deletedPrivateMediaRelativePaths.removeAll() + protectedPrivateMediaRelativePaths.removeAll() sentVerifyChallenges.removeAll() sentVerifyResponses.removeAll() startServicesCallCount = 0 diff --git a/bitchatTests/Services/BLEFileTransferHandlerTests.swift b/bitchatTests/Services/BLEFileTransferHandlerTests.swift index 618aa070..b17aafd2 100644 --- a/bitchatTests/Services/BLEFileTransferHandlerTests.swift +++ b/bitchatTests/Services/BLEFileTransferHandlerTests.swift @@ -21,9 +21,12 @@ struct BLEFileTransferHandlerTests { var receiptCommits: [(messageID: String, storedURL: URL)] = [] var receiptCommitSucceeds = true var removedIncomingFiles: [URL] = [] + var finishedIncomingFileDeliveries: [URL] = [] var lastSeenUpdates: [PeerID] = [] var deliveryAcks: [(messageID: String, peerID: PeerID)] = [] var deliveredMessages: [BitchatMessage] = [] + var shouldAcceptDelivery = true + var deliveryOutcome = TransportEventDeliveryOutcome.accepted var saveOverride: (( _ data: Data, _ preferredName: String?, @@ -34,12 +37,85 @@ struct BLEFileTransferHandlerTests { var receiptStateOverride: ((String) -> BLEPrivateMediaReceiptState)? var receiptCommitOverride: ((String, URL) -> Bool)? var removeIncomingFileOverride: ((URL) -> Void)? + var finishIncomingFileDeliveryOverride: ((URL) -> Void)? } private let localPeerID = PeerID(str: "0102030405060708") private let remotePeerID = PeerID(str: "1122334455667788") private let sampleSigningKey = Data(repeating: 0xAB, count: 32) + @Test @MainActor + func deliveryGateFinalizesInitialRejection() { + var completions = 0 + var finalizations = 0 + + TransportEventDeliveryGate.attempt( + shouldDeliver: { false }, + deliver: { + Issue.record("delivery sink must not run") + return .accepted + }, + completion: { completions += 1 }, + finalization: { _ in finalizations += 1 } + ) + + #expect(completions == 0) + #expect(finalizations == 1) + } + + @Test @MainActor + func deliveryGateFinalizesMissingOrRejectingSink() { + var completions = 0 + var finalizations = 0 + + TransportEventDeliveryGate.attempt( + shouldDeliver: { true }, + deliver: { .rejected }, + completion: { completions += 1 }, + finalization: { _ in finalizations += 1 } + ) + + #expect(completions == 0) + #expect(finalizations == 1) + } + + @Test @MainActor + func deliveryGateFinalizesPostInsertionRejection() { + var deliveryChecks = 0 + var completions = 0 + var finalizations = 0 + + TransportEventDeliveryGate.attempt( + shouldDeliver: { + deliveryChecks += 1 + return deliveryChecks == 1 + }, + deliver: { .accepted }, + completion: { completions += 1 }, + finalization: { _ in finalizations += 1 } + ) + + #expect(deliveryChecks == 2) + #expect(completions == 0) + #expect(finalizations == 1) + } + + @Test @MainActor + func deliveryGatePreservesInvokedUnconfirmedOutcomeWithoutAck() { + var completions = 0 + var outcomes: [TransportEventDeliveryOutcome] = [] + + TransportEventDeliveryGate.attempt( + shouldDeliver: { true }, + deliver: { .invokedUnconfirmed }, + completion: { completions += 1 }, + finalization: { outcomes.append($0) } + ) + + #expect(completions == 0) + #expect(outcomes == [.invokedUnconfirmed]) + } + private func makeHandler(recorder: Recorder) -> BLEFileTransferHandler { let environment = BLEFileTransferHandlerEnvironment( localPeerID: { [localPeerID] in localPeerID }, @@ -86,6 +162,10 @@ struct BLEFileTransferHandlerTests { recorder.removedIncomingFiles.append(storedURL) recorder.removeIncomingFileOverride?(storedURL) }, + finishIncomingFileDelivery: { storedURL in + recorder.finishedIncomingFileDeliveries.append(storedURL) + recorder.finishIncomingFileDeliveryOverride?(storedURL) + }, isPrivateMediaSenderBlocked: { peerID in recorder.blockedPeers.contains(peerID) }, @@ -95,10 +175,18 @@ struct BLEFileTransferHandlerTests { acknowledgePrivateMedia: { messageID, peerID in recorder.deliveryAcks.append((messageID, peerID)) }, - deliverMessage: { message, shouldDeliver, completion in + deliverMessage: { message, shouldDeliver, completion, finalization in + var outcome = TransportEventDeliveryOutcome.rejected + defer { finalization(outcome) } + guard recorder.shouldAcceptDelivery else { return } guard shouldDeliver() else { return } recorder.deliveredMessages.append(message) guard shouldDeliver() else { return } + if recorder.deliveryOutcome == .invokedUnconfirmed { + outcome = .invokedUnconfirmed + return + } + outcome = .accepted completion() } ) @@ -375,6 +463,73 @@ struct BLEFileTransferHandlerTests { #expect(recorder.deliveryAcks.first?.messageID == recorder.deliveredMessages.first?.id) } + @Test + func rejectedStableDeliveryReleasesPendingPayloadOwnership() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "private-media-handler-rejected-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: root) } + let store = BLEIncomingFileStore(baseDirectory: root) + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo( + remotePeerID, + nickname: "Alice", + isVerified: true + )] + recorder.shouldAcceptDelivery = false + recorder.saveOverride = { + store.save( + data: $0, + preferredName: $1, + subdirectory: $2, + fallbackExtension: $3, + defaultPrefix: $4 + ) + } + recorder.receiptStateOverride = { + store.privateMediaReceiptState(messageID: $0) + } + recorder.receiptCommitOverride = { + store.commitPrivateMediaFile(messageID: $0, storedURL: $1) + } + recorder.removeIncomingFileOverride = { + store.removeIncomingFile(at: $0) + } + recorder.finishIncomingFileDeliveryOverride = { + store.finishIncomingFileDelivery(at: $0) + } + + let content = Data([0xFF, 0xD8, 0xFF, 0xD9]) + let fileName = + "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg" + let file = BitchatFilePacket( + fileName: fileName, + fileSize: UInt64(content.count), + mimeType: "image/jpeg", + content: content + ) + let payload = try #require(file.encode()) + let stableID = try #require(PrivateMediaMessageIdentity.stableID( + senderPeerID: remotePeerID, + recipientPeerID: localPeerID, + fileName: fileName + )) + + #expect(makeHandler(recorder: recorder).handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_234) + )) + #expect(recorder.deliveredMessages.isEmpty) + #expect(recorder.deliveryAcks.isEmpty) + #expect(recorder.finishedIncomingFileDeliveries.count == 1) + #expect(store.reservePrivateMediaDeletion( + messageIDs: [stableID], + payloadRelativePaths: [:] + ) != nil) + } + @Test func rawLegacyPrivateFileWithRetryShapedNameNeverUsesReceiptLedger() throws { let recorder = Recorder() @@ -402,6 +557,64 @@ struct BLEFileTransferHandlerTests { #expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false) } + @Test + func rejectedRawDeliveryRemovesUIUnownedPayload() throws { + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo( + remotePeerID, + nickname: "Alice", + isVerified: true, + signingPublicKey: sampleSigningKey + )] + recorder.signatureVerifies = true + recorder.shouldAcceptDelivery = false + let handler = makeHandler(recorder: recorder) + let packet = try makeFileTransferPacket( + sender: remotePeerID, + mimeType: "image/jpeg", + content: Data([0xFF, 0xD8, 0xFF, 0xD9]), + recipientID: Data(hexString: localPeerID.id), + fileName: "raw-rejected.jpg" + ) + + #expect(handler.handle(packet, from: remotePeerID)) + #expect(recorder.deliveredMessages.isEmpty) + #expect(recorder.removedIncomingFiles.count == 1) + #expect(recorder.removedIncomingFiles.first == recorder.saveResult) + #expect(recorder.finishedIncomingFileDeliveries.isEmpty) + } + + @Test + func plainDelegateRawDeliveryPreservesPayloadWithoutSynchronousAck() throws { + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo( + remotePeerID, + nickname: "Alice", + isVerified: true, + signingPublicKey: sampleSigningKey + )] + recorder.signatureVerifies = true + recorder.deliveryOutcome = .invokedUnconfirmed + let handler = makeHandler(recorder: recorder) + let packet = try makeFileTransferPacket( + sender: remotePeerID, + mimeType: "image/jpeg", + content: Data([0xFF, 0xD8, 0xFF, 0xD9]), + recipientID: Data(hexString: localPeerID.id), + fileName: "plain-delegate.jpg" + ) + + #expect(handler.handle(packet, from: remotePeerID)) + #expect(recorder.deliveredMessages.count == 1) + #expect(recorder.deliveryAcks.isEmpty) + #expect(recorder.removedIncomingFiles.isEmpty) + #expect(recorder.finishedIncomingFileDeliveries.count == 1) + #expect( + recorder.finishedIncomingFileDeliveries.first + == recorder.saveResult + ) + } + @Test func repeatedLegacyPrivateImageNamesKeepDistinctRandomMessageIDs() throws { let recorder = Recorder() @@ -502,12 +715,7 @@ struct BLEFileTransferHandlerTests { nickname: "Alice", isVerified: true )] - recorder.saveOverride = { - data, - preferredName, - subdirectory, - fallbackExtension, - defaultPrefix in + recorder.saveOverride = { data, preferredName, subdirectory, fallbackExtension, defaultPrefix in store.save( data: data, preferredName: preferredName, @@ -525,6 +733,9 @@ struct BLEFileTransferHandlerTests { recorder.removeIncomingFileOverride = { store.removeIncomingFile(at: $0) } + recorder.finishIncomingFileDeliveryOverride = { + store.finishIncomingFileDelivery(at: $0) + } } let first = Recorder() @@ -880,6 +1091,21 @@ struct BLEFileTransferHandlerTests { let messageID = "media-00112233445566778899aabbccddeeff" let seed = BLEPrivateMediaReceiptStore(baseDirectory: base) + let payload = base + .appendingPathComponent( + "files/images/incoming", + isDirectory: true + ) + .appendingPathComponent("panic-receipt.jpg") + try FileManager.default.createDirectory( + at: payload.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data("secret".utf8).write(to: payload, options: .atomic) + #expect(seed.commitAccepted( + messageID: messageID, + storedURL: payload + )) #expect(seed.recordDeleted(messageID: messageID)) let store = BLEIncomingFileStore(baseDirectory: base) diff --git a/bitchatTests/Services/BLEPrivateMediaReceiptStoreTests.swift b/bitchatTests/Services/BLEPrivateMediaReceiptStoreTests.swift index 9efa71ce..7b40aa0b 100644 --- a/bitchatTests/Services/BLEPrivateMediaReceiptStoreTests.swift +++ b/bitchatTests/Services/BLEPrivateMediaReceiptStoreTests.swift @@ -4,8 +4,22 @@ import Testing struct BLEPrivateMediaReceiptStoreTests { private struct TestError: Error {} + private struct ReceiptFixture: Codable { + let kind: String + let relativePath: String? + let recordedAt: Date + } + private struct JournalEntryFixture: Codable { + let relativePaths: [String] + let recordedAt: Date + } + private struct JournalFixture: Codable { + let version: Int + let entries: [String: JournalEntryFixture] + } private let messageID = "media-00112233445566778899aabbccddeeff" + private let secondMessageID = "media-ffeeddccbbaa99887766554433221100" @Test func acceptedReceiptPersistsAcrossStoreInstances() throws { @@ -21,6 +35,215 @@ struct BLEPrivateMediaReceiptStoreTests { #expect(relaunched.state(for: messageID) == .accepted(payload)) } + @Test + func acceptedReceiptRejectsOutgoingPayloadAndCrossIDPathReuse() throws { + let root = makeRoot("accepted-ownership") + defer { try? FileManager.default.removeItem(at: root) } + let incoming = try makePayload(in: root) + let outgoingDirectory = root.appendingPathComponent( + "files/images/outgoing", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: outgoingDirectory, + withIntermediateDirectories: true + ) + let outgoing = outgoingDirectory.appendingPathComponent("image.jpg") + try Data([0x01]).write(to: outgoing) + let store = BLEPrivateMediaReceiptStore(baseDirectory: root) + + #expect(!store.commitAccepted( + messageID: messageID, + storedURL: outgoing + )) + #expect(store.commitAccepted( + messageID: messageID, + storedURL: incoming + )) + #expect(!store.commitAccepted( + messageID: secondMessageID, + storedURL: incoming + )) + #expect(FileManager.default.fileExists(atPath: incoming.path)) + #expect(FileManager.default.fileExists(atPath: outgoing.path)) + } + + @Test + func liveAcceptedPathSurvivesTTLAndCapacityPressure() throws { + let root = makeRoot("accepted-retention") + defer { try? FileManager.default.removeItem(at: root) } + let firstPayload = try makePayload(in: root, name: "first.jpg") + let secondPayload = try makePayload(in: root, name: "second.jpg") + let recordedAt = Date(timeIntervalSince1970: 2_000) + let seed = BLEPrivateMediaReceiptStore( + baseDirectory: root, + capacity: 1, + ttl: 1, + now: { recordedAt } + ) + #expect(seed.commitAccepted( + messageID: messageID, + storedURL: firstPayload + )) + + let relaunched = BLEPrivateMediaReceiptStore( + baseDirectory: root, + capacity: 1, + ttl: 1, + now: { recordedAt.addingTimeInterval(2) } + ) + #expect(relaunched.state(for: messageID) == .accepted(firstPayload)) + #expect(!relaunched.commitAccepted( + messageID: secondMessageID, + storedURL: secondPayload + )) + #expect(relaunched.state(for: messageID) == .accepted(firstPayload)) + } + + @Test + func incomingAllocationDoesNotReuseReceiptOwnedMissingPath() throws { + let root = makeRoot("accepted-reservation") + defer { try? FileManager.default.removeItem(at: root) } + let original = try makePayload(in: root) + #expect(BLEPrivateMediaReceiptStore( + baseDirectory: root + ).commitAccepted( + messageID: messageID, + storedURL: original + )) + try FileManager.default.removeItem(at: original) + + let stored = BLEIncomingFileStore(baseDirectory: root).save( + data: Data([0x03]), + preferredName: "image.jpg", + subdirectory: "images/incoming", + fallbackExtension: "jpg", + defaultPrefix: "image" + ) + + #expect(stored?.lastPathComponent != "image.jpg") + #expect(stored.map { + FileManager.default.fileExists(atPath: $0.path) + } == true) + } + + @Test + func pendingRawArrivalBlocksDeletionFallbackPathReuse() throws { + let root = makeRoot("pending-raw-arrival") + defer { try? FileManager.default.removeItem(at: root) } + let incoming = BLEIncomingFileStore(baseDirectory: root) + + // The old stable bubble names image.jpg, but its receipt and payload + // have already been pruned. A raw arrival saves that basename before + // its main-actor bubble is inserted. + let rawArrival = incoming.save( + data: Data([0x03]), + preferredName: "image.jpg", + subdirectory: "images/incoming", + fallbackExtension: "jpg", + defaultPrefix: "image" + ) + #expect(rawArrival?.lastPathComponent == "image.jpg") + + let reservation = incoming.reservePrivateMediaDeletion( + messageIDs: [messageID], + payloadRelativePaths: [ + messageID: "images/incoming/image.jpg" + ] + ) + #expect(reservation == nil) + #expect(rawArrival.map { + FileManager.default.fileExists(atPath: $0.path) + } == true) + } + + @Test + func quotaDoesNotEvictOrReusePendingDeliveryPath() throws { + let root = makeRoot("pending-quota") + defer { try? FileManager.default.removeItem(at: root) } + let incoming = BLEIncomingFileStore( + baseDirectory: root, + quotaBytes: 1 + ) + let first = try #require(incoming.save( + data: Data([0x01, 0x02]), + preferredName: "image.jpg", + subdirectory: "images/incoming", + fallbackExtension: "jpg", + defaultPrefix: "image" + )) + + incoming.enforceQuota(reservingBytes: 1) + #expect(FileManager.default.fileExists(atPath: first.path)) + + let second = try #require(incoming.save( + data: Data([0x03]), + preferredName: "image.jpg", + subdirectory: "images/incoming", + fallbackExtension: "jpg", + defaultPrefix: "image" + )) + #expect(second != first) + #expect(second.lastPathComponent == "image (1).jpg") + #expect(FileManager.default.fileExists(atPath: first.path)) + #expect(FileManager.default.fileExists(atPath: second.path)) + } + + @Test + func invalidReceiptAndJournalCannotTargetOutgoingPayload() throws { + let root = makeRoot("invalid-owned-path") + defer { try? FileManager.default.removeItem(at: root) } + let outgoingDirectory = root.appendingPathComponent( + "files/images/outgoing", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: outgoingDirectory, + withIntermediateDirectories: true + ) + let victim = outgoingDirectory.appendingPathComponent("victim.jpg") + try Data([0x02]).write(to: victim) + let receiptURL = receiptRecord(in: root) + try FileManager.default.createDirectory( + at: receiptURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let fixture = ReceiptFixture( + kind: "tombstone", + relativePath: "images/outgoing/victim.jpg", + recordedAt: Date() + ) + try JSONEncoder().encode(fixture).write( + to: receiptURL, + options: .atomic + ) + + #expect( + BLEPrivateMediaReceiptStore(baseDirectory: root) + .state(for: messageID) == .unavailable + ) + #expect(FileManager.default.fileExists(atPath: victim.path)) + + try FileManager.default.removeItem(at: receiptURL) + let journal = JournalFixture( + version: 1, + entries: [messageID: JournalEntryFixture( + relativePaths: ["images/outgoing/victim.jpg"], + recordedAt: fixture.recordedAt + )] + ) + try JSONEncoder().encode(journal).write( + to: deletionJournal(in: root), + options: .atomic + ) + + #expect( + BLEPrivateMediaReceiptStore(baseDirectory: root) + .state(for: messageID) == .unavailable + ) + #expect(FileManager.default.fileExists(atPath: victim.path)) + } + @Test func directoryEnumerationFailureIsUnavailableAndRetriesWithoutCachingEmpty() throws { let root = makeRoot("list-failure") @@ -128,25 +351,447 @@ struct BLEPrivateMediaReceiptStoreTests { } @Test - func failedTombstonePersistenceDoesNotPoisonVolatileState() throws { - let root = makeRoot("failed-tombstone-write") + func deletionBatchCommitsEveryIDBeforeRemovingPayloads() throws { + let root = makeRoot("batch") + defer { try? FileManager.default.removeItem(at: root) } + let firstPayload = try makePayload(in: root, name: "first.jpg") + let secondPayload = try makePayload(in: root, name: "second.jpg") + let store = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(store.commitAccepted( + messageID: messageID, + storedURL: firstPayload + )) + #expect(store.commitAccepted( + messageID: secondMessageID, + storedURL: secondPayload + )) + + #expect(store.recordDeleted( + messageIDs: [secondMessageID, messageID] + )) + + #expect(store.state(for: messageID) == .tombstoned) + #expect(store.state(for: secondMessageID) == .tombstoned) + #expect(!FileManager.default.fileExists(atPath: firstPayload.path)) + #expect(!FileManager.default.fileExists(atPath: secondPayload.path)) + #expect(!FileManager.default.fileExists( + atPath: deletionJournal(in: root).path + )) + } + + @Test + func failedJournalCommitPreservesAcceptedStateAndPayloads() throws { + let root = makeRoot("journal-failure") + defer { try? FileManager.default.removeItem(at: root) } + let firstPayload = try makePayload(in: root, name: "first.jpg") + let secondPayload = try makePayload(in: root, name: "second.jpg") + let seed = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(seed.commitAccepted( + messageID: messageID, + storedURL: firstPayload + )) + #expect(seed.commitAccepted( + messageID: secondMessageID, + storedURL: secondPayload + )) + + let failing = BLEPrivateMediaReceiptStore( + baseDirectory: root, + dataWriter: { _, _, _ in throw TestError() } + ) + #expect(!failing.recordDeleted( + messageIDs: [messageID, secondMessageID] + )) + + // A failed commit must not install a volatile tombstone. Otherwise a + // sender retry could be ACKed although the caller kept both bubbles. + #expect(failing.state(for: messageID) == .accepted(firstPayload)) + #expect( + failing.state(for: secondMessageID) == .accepted(secondPayload) + ) + #expect(FileManager.default.fileExists(atPath: firstPayload.path)) + #expect(FileManager.default.fileExists(atPath: secondPayload.path)) + #expect(!FileManager.default.fileExists( + atPath: deletionJournal(in: root).path + )) + } + + @Test + func journalRecoversBatchAfterCrashDuringMaterialization() throws { + let root = makeRoot("crash-recovery") + defer { try? FileManager.default.removeItem(at: root) } + let firstPayload = try makePayload(in: root, name: "first.jpg") + let secondPayload = try makePayload(in: root, name: "second.jpg") + let seed = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(seed.commitAccepted( + messageID: messageID, + storedURL: firstPayload + )) + #expect(seed.commitAccepted( + messageID: secondMessageID, + storedURL: secondPayload + )) + + let interrupted = BLEPrivateMediaReceiptStore( + baseDirectory: root, + dataWriter: { data, url, options in + let isJournal = + url.lastPathComponent == ".deletion-journal.json" + let isFirstRecord = + url.deletingPathExtension().lastPathComponent == messageID + guard isJournal || isFirstRecord else { + throw TestError() + } + try data.write(to: url, options: options) + } + ) + #expect(interrupted.recordDeleted( + messageIDs: [messageID, secondMessageID] + )) + #expect(FileManager.default.fileExists( + atPath: deletionJournal(in: root).path + )) + #expect(!FileManager.default.fileExists(atPath: firstPayload.path)) + #expect(FileManager.default.fileExists(atPath: secondPayload.path)) + #expect(interrupted.state(for: messageID) == .tombstoned) + #expect(interrupted.state(for: secondMessageID) == .tombstoned) + + let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(relaunched.state(for: messageID) == .tombstoned) + #expect(relaunched.state(for: secondMessageID) == .tombstoned) + #expect(!FileManager.default.fileExists(atPath: firstPayload.path)) + #expect(!FileManager.default.fileExists(atPath: secondPayload.path)) + #expect(!FileManager.default.fileExists( + atPath: deletionJournal(in: root).path + )) + } + + @Test + func journalRetriesPayloadUnlinkAfterRelaunch() throws { + let root = makeRoot("unlink-recovery") + defer { try? FileManager.default.removeItem(at: root) } + let payload = try makePayload(in: root) + let seed = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(seed.commitAccepted( + messageID: messageID, + storedURL: payload + )) + + let unlinkFailure = BLEPrivateMediaReceiptStore( + baseDirectory: root, + payloadRemover: { _ in throw TestError() } + ) + #expect(unlinkFailure.recordDeleted(messageID: messageID)) + #expect(unlinkFailure.state(for: messageID) == .tombstoned) + #expect(FileManager.default.fileExists(atPath: payload.path)) + #expect(FileManager.default.fileExists( + atPath: deletionJournal(in: root).path + )) + + let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(relaunched.state(for: messageID) == .tombstoned) + #expect(!FileManager.default.fileExists(atPath: payload.path)) + #expect(!FileManager.default.fileExists( + atPath: deletionJournal(in: root).path + )) + } + + @Test + func expiredReceiptUsesFallbackPathForCrashSafeCleanup() throws { + let root = makeRoot("expired-fallback") + defer { try? FileManager.default.removeItem(at: root) } + let payload = try makePayload(in: root) + let recordedAt = Date(timeIntervalSince1970: 1_000) + let seed = BLEPrivateMediaReceiptStore( + baseDirectory: root, + ttl: 10, + now: { recordedAt } + ) + #expect(seed.commitAccepted( + messageID: messageID, + storedURL: payload + )) + // Simulate a receipt pruned by an older app version while its bubble + // and payload remain. Current code retains live accepted-path owners. + try FileManager.default.removeItem(at: receiptRecord(in: root)) + + let afterExpiry = recordedAt.addingTimeInterval(11) + let interrupted = BLEPrivateMediaReceiptStore( + baseDirectory: root, + ttl: 10, + now: { afterExpiry }, + payloadRemover: { _ in throw TestError() } + ) + #expect(interrupted.recordDeleted( + messageIDs: [messageID], + payloadRelativePaths: [ + messageID: "images/incoming/image.jpg" + ] + )) + #expect(interrupted.state(for: messageID) == .tombstoned) + #expect(FileManager.default.fileExists(atPath: payload.path)) + #expect(FileManager.default.fileExists( + atPath: deletionJournal(in: root).path + )) + + let relaunched = BLEPrivateMediaReceiptStore( + baseDirectory: root, + ttl: 10, + now: { afterExpiry } + ) + #expect(relaunched.state(for: messageID) == .tombstoned) + #expect(!FileManager.default.fileExists(atPath: payload.path)) + #expect(!FileManager.default.fileExists( + atPath: deletionJournal(in: root).path + )) + } + + @Test + func retryAtSuffixedPathDeletesReceiptAndBubblePayloads() throws { + let root = makeRoot("retry-suffix") + defer { try? FileManager.default.removeItem(at: root) } + let original = try makePayload(in: root) + let seed = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(seed.commitAccepted( + messageID: messageID, + storedURL: original + )) + + // Simulate an older build pruning only the receipt. A retry must use a + // suffixed filename while the old bubble still references image.jpg. + try FileManager.default.removeItem(at: receiptRecord(in: root)) + let retry = try makePayload(in: root, name: "image (1).jpg") + let retried = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(retried.commitAccepted( + messageID: messageID, + storedURL: retry + )) + + #expect(retried.recordDeleted( + messageIDs: [messageID], + payloadRelativePaths: [ + messageID: "images/incoming/image.jpg" + ] + )) + #expect(retried.state(for: messageID) == .tombstoned) + #expect(!FileManager.default.fileExists(atPath: original.path)) + #expect(!FileManager.default.fileExists(atPath: retry.path)) + #expect(!FileManager.default.fileExists( + atPath: deletionJournal(in: root).path + )) + + let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(relaunched.state(for: messageID) == .tombstoned) + #expect(!FileManager.default.fileExists(atPath: original.path)) + #expect(!FileManager.default.fileExists(atPath: retry.path)) + } + + @Test + func protectedUIPathRejectsAcceptedReceiptDeletion() throws { + let root = makeRoot("protected-ui-owner") + defer { try? FileManager.default.removeItem(at: root) } + let payload = try makePayload(in: root) + let store = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(store.commitAccepted( + messageID: messageID, + storedURL: payload + )) + + #expect(!store.recordDeleted( + messageIDs: [messageID], + protectedPayloadRelativePaths: [ + "images/incoming/image.jpg" + ] + )) + #expect(store.state(for: messageID) == .accepted(payload)) + #expect(FileManager.default.fileExists(atPath: payload.path)) + } + + @Test + func pathlessNewDeletionFailsWithoutChangingReceiverState() { + let root = makeRoot("pathless-delete") defer { try? FileManager.default.removeItem(at: root) } let store = BLEPrivateMediaReceiptStore(baseDirectory: root) - #expect(store.state(for: messageID) == .absent) - // Force the atomic record write itself to fail after the store has - // successfully loaded its empty index. - let record = receiptRecord(in: root) + #expect(!store.recordDeleted(messageID: messageID)) + #expect(store.state(for: messageID) == .absent) + #expect(!FileManager.default.fileExists( + atPath: deletionJournal(in: root).path + )) + } + + @Test + func completedTombstoneNeverDeletesReusedPayloadPath() throws { + let root = makeRoot("path-reuse") + defer { try? FileManager.default.removeItem(at: root) } + let original = try makePayload(in: root) + let store = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(store.commitAccepted( + messageID: messageID, + storedURL: original + )) + #expect(store.recordDeleted(messageID: messageID)) + #expect(!FileManager.default.fileExists(atPath: original.path)) + + let reused = try makePayload(in: root) + #expect(store.commitAccepted( + messageID: secondMessageID, + storedURL: reused + )) + #expect(store.state(for: messageID) == .tombstoned) + #expect(FileManager.default.fileExists(atPath: reused.path)) + + let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(relaunched.state(for: messageID) == .tombstoned) + #expect(FileManager.default.fileExists(atPath: reused.path)) + #expect( + relaunched.state(for: secondMessageID) == .accepted(reused) + ) + } + + @Test + func expiredLegacyPathfulTombstoneDoesNotDeleteAcceptedOwner() throws { + let root = makeRoot("legacy-path-conflict") + defer { try? FileManager.default.removeItem(at: root) } + let payload = try makePayload(in: root) + let receiptDirectory = receiptRecord(in: root) + .deletingLastPathComponent() try FileManager.default.createDirectory( - at: record, + at: receiptDirectory, withIntermediateDirectories: true ) - #expect(!store.recordDeleted(messageID: messageID)) - try FileManager.default.removeItem(at: record) + let recordedAt = Date(timeIntervalSince1970: 3_000) + try JSONEncoder().encode(ReceiptFixture( + kind: "tombstone", + relativePath: "images/incoming/image.jpg", + recordedAt: recordedAt + )).write( + to: receiptRecord(in: root), + options: .atomic + ) + try JSONEncoder().encode(ReceiptFixture( + kind: "accepted", + relativePath: "images/incoming/image.jpg", + recordedAt: recordedAt + )).write( + to: receiptRecord( + in: root, + messageID: secondMessageID + ), + options: .atomic + ) - // The UI must be able to report the deletion failure without a - // process-lifetime tombstone silently hiding a later retry. + let store = BLEPrivateMediaReceiptStore( + baseDirectory: root, + ttl: 1, + now: { recordedAt.addingTimeInterval(2) } + ) #expect(store.state(for: messageID) == .absent) + #expect(FileManager.default.fileExists(atPath: payload.path)) + #expect( + store.state(for: secondMessageID) == .accepted(payload) + ) + #expect(FileManager.default.fileExists(atPath: payload.path)) + } + + @Test + func expiredLegacyPathfulTombstonePreservesAmbiguousPayload() throws { + let root = makeRoot("legacy-expired-cleanup") + defer { try? FileManager.default.removeItem(at: root) } + let payload = try makePayload(in: root) + let receiptURL = receiptRecord(in: root) + try FileManager.default.createDirectory( + at: receiptURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let recordedAt = Date(timeIntervalSince1970: 3_000) + try JSONEncoder().encode(ReceiptFixture( + kind: "tombstone", + relativePath: "images/incoming/image.jpg", + recordedAt: recordedAt + )).write(to: receiptURL, options: .atomic) + + let store = BLEPrivateMediaReceiptStore( + baseDirectory: root, + ttl: 1, + now: { recordedAt.addingTimeInterval(2) } + ) + + #expect(store.state(for: messageID) == .absent) + #expect(FileManager.default.fileExists(atPath: payload.path)) + #expect(!FileManager.default.fileExists(atPath: receiptURL.path)) + } + + @Test + func deletionJournalNeverRecursivelyRemovesDirectoryTarget() throws { + let root = makeRoot("journal-directory") + defer { try? FileManager.default.removeItem(at: root) } + let directory = root.appendingPathComponent( + "files/images/incoming/archive", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + let child = directory.appendingPathComponent("child.jpg") + try Data([0x01]).write(to: child) + let receiptDirectory = receiptRecord(in: root) + .deletingLastPathComponent() + try FileManager.default.createDirectory( + at: receiptDirectory, + withIntermediateDirectories: true + ) + #expect(!BLEPrivateMediaReceiptStore( + baseDirectory: root + ).recordDeleted( + messageIDs: [messageID], + payloadRelativePaths: [ + messageID: "images/incoming/archive" + ] + )) + #expect(!FileManager.default.fileExists( + atPath: deletionJournal(in: root).path + )) + try JSONEncoder().encode(JournalFixture( + version: 1, + entries: [messageID: JournalEntryFixture( + relativePaths: ["images/incoming/archive"], + recordedAt: Date() + )] + )).write(to: deletionJournal(in: root), options: .atomic) + + let store = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(store.state(for: messageID) == .tombstoned) + #expect(FileManager.default.fileExists(atPath: directory.path)) + #expect(FileManager.default.fileExists(atPath: child.path)) + #expect(FileManager.default.fileExists( + atPath: deletionJournal(in: root).path + )) + } + + @Test + func corruptDeletionJournalFailsClosedWithoutRemovingPayload() throws { + let root = makeRoot("corrupt-journal") + defer { try? FileManager.default.removeItem(at: root) } + let payload = try makePayload(in: root) + #expect(BLEPrivateMediaReceiptStore( + baseDirectory: root + ).commitAccepted( + messageID: messageID, + storedURL: payload + )) + let journal = deletionJournal(in: root) + try Data("{not-json".utf8).write(to: journal, options: .atomic) + + let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(relaunched.state(for: messageID) == .unavailable) + #expect(!relaunched.commitAccepted( + messageID: messageID, + storedURL: payload + )) + #expect(FileManager.default.fileExists(atPath: payload.path)) + #expect(try Data(contentsOf: journal) == Data("{not-json".utf8)) } @Test @@ -180,7 +825,10 @@ struct BLEPrivateMediaReceiptStoreTests { ) } - private func makePayload(in root: URL) throws -> URL { + private func makePayload( + in root: URL, + name: String = "image.jpg" + ) throws -> URL { let directory = root.appendingPathComponent( "files/images/incoming", isDirectory: true @@ -189,18 +837,30 @@ struct BLEPrivateMediaReceiptStoreTests { at: directory, withIntermediateDirectories: true ) - let payload = directory.appendingPathComponent("image.jpg") + let payload = directory.appendingPathComponent(name) try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: payload) return payload } - private func receiptRecord(in root: URL) -> URL { + private func receiptRecord( + in root: URL, + messageID requestedMessageID: String? = nil + ) -> URL { root .appendingPathComponent( "files/.private-media-receipts", isDirectory: true ) - .appendingPathComponent(messageID) + .appendingPathComponent(requestedMessageID ?? messageID) .appendingPathExtension("json") } + + private func deletionJournal(in root: URL) -> URL { + root + .appendingPathComponent( + "files/.private-media-receipts", + isDirectory: true + ) + .appendingPathComponent(".deletion-journal.json") + } }