From 075306e650d55a85aa9b17efba499c1c6a5a4d89 Mon Sep 17 00:00:00 2001 From: jack Date: Sat, 25 Jul 2026 17:31:29 +0200 Subject: [PATCH] Persist private media delivery receipts --- .../Protocols/PeerCapabilities+Local.swift | 8 +- .../Services/BLE/BLEFileTransferHandler.swift | 222 +++++-- .../Services/BLE/BLEIncomingFileStore.swift | 47 ++ .../BLE/BLEPrivateMediaReceiptStore.swift | 556 ++++++++++++++++++ bitchat/Services/BLE/BLEService.swift | 79 ++- bitchat/Services/TransportConfig.swift | 7 + ...MediaTransferCoordinatorContextTests.swift | 54 +- ...ransportEventCoordinatorContextTests.swift | 11 +- .../EndToEnd/PrivateMediaEndToEndTests.swift | 9 +- .../BLEFileTransferHandlerTests.swift | 268 ++++++++- .../BLENoiseReconnectPolicyTests.swift | 3 + .../BLEPrivateMediaReceiptStoreTests.swift | 206 +++++++ docs/PRIVATE-MEDIA-MIGRATION.md | 7 + .../BitFoundation/PeerCapabilities.swift | 7 + .../PeerCapabilitiesTests.swift | 22 +- 15 files changed, 1434 insertions(+), 72 deletions(-) create mode 100644 bitchat/Services/BLE/BLEPrivateMediaReceiptStore.swift create mode 100644 bitchatTests/Services/BLEPrivateMediaReceiptStoreTests.swift diff --git a/bitchat/Protocols/PeerCapabilities+Local.swift b/bitchat/Protocols/PeerCapabilities+Local.swift index 8cae9798..d48891d7 100644 --- a/bitchat/Protocols/PeerCapabilities+Local.swift +++ b/bitchat/Protocols/PeerCapabilities+Local.swift @@ -3,5 +3,11 @@ import BitFoundation extension PeerCapabilities { /// Capabilities this build advertises in its announce packets. /// Each feature adds its bit here when it ships. - static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups, .privateMedia] + static let localSupported: PeerCapabilities = [ + .vouch, + .prekeys, + .groups, + .privateMedia, + .privateMediaReceipts + ] } diff --git a/bitchat/Services/BLE/BLEFileTransferHandler.swift b/bitchat/Services/BLE/BLEFileTransferHandler.swift index 799d9892..f2e17b6d 100644 --- a/bitchat/Services/BLE/BLEFileTransferHandler.swift +++ b/bitchat/Services/BLE/BLEFileTransferHandler.swift @@ -32,54 +32,78 @@ struct BLEFileTransferHandlerEnvironment { _ fallbackExtension: String?, _ defaultPrefix: String ) -> URL? + /// Resolves the durable receiver decision for a stable private-media ID. + let privateMediaReceiptState: ( + _ messageID: String + ) -> BLEPrivateMediaReceiptState + /// Atomically records a stable private-media ID after the payload save. + let commitPrivateMediaFile: (_ messageID: String, _ storedURL: URL) -> Bool + /// Rolls back a saved payload when its durable receipt commit fails. + let removeIncomingFile: (_ 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). let updatePeerLastSeen: (PeerID) -> Void - /// Re-acknowledges a stable private-media duplicate without saving or - /// re-delivering it. This lets a sender recover from a lost ACK. - let acknowledgePrivateMediaDuplicate: (_ messageID: String, _ peerID: PeerID) -> Void - /// Delivers `.messageReceived` to the UI as one main-actor hop. - let deliverMessage: (BitchatMessage) -> Void + /// Acknowledges stable private media only after its synchronous + /// conversation delivery has completed. + 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. + let deliverMessage: ( + _ message: BitchatMessage, + _ shouldDeliver: @escaping () -> Bool, + _ completion: @escaping () -> Void + ) -> Void } /// Process-lifetime reservation cache for stable private-media IDs. /// -/// The first arrival reserves its ID before quota enforcement and commits it -/// only after durable save. Concurrent/retried arrivals are rejected before -/// they can create uniquified orphan files or churn the incoming-media quota. +/// The first arrival reserves its ID before quota enforcement. Concurrent +/// arrivals remain coalesced in memory, while accepted state is resolved from +/// the durable ID-to-file ledger so it survives relaunch and becomes retryable +/// if quota cleanup removed the file. private final class PrivateMediaArrivalDeduplicator { enum Reservation { case reserved case pending - case accepted + case accepted(URL) + case tombstoned + case unavailable } private let lock = NSLock() - private var accepted = BoundedIDSet(capacity: 4_096) private var pending: Set = [] - func reserve(_ messageID: String) -> Reservation { + func reserve( + _ messageID: String, + receiptState: () -> BLEPrivateMediaReceiptState + ) -> Reservation { lock.lock() defer { lock.unlock() } - if accepted.contains(messageID) { - return .accepted - } if pending.contains(messageID) { return .pending } + switch receiptState() { + case .accepted(let existingURL): + return .accepted(existingURL) + case .tombstoned: + return .tombstoned + case .unavailable: + return .unavailable + case .absent: + break + } + pending.insert(messageID) return .reserved } - func finish(_ messageID: String, accepted didAccept: Bool) { + func finish(_ messageID: String) { lock.lock() defer { lock.unlock() } pending.remove(messageID) - if didAccept { - accepted.insert(messageID) - } } } @@ -132,6 +156,7 @@ final class BLEFileTransferHandler { senderNickname: senderNickname, timestamp: Date(timeIntervalSince1970: Double(packet.timestamp) / 1000), isPrivate: deliveryPlan.isPrivateMessage, + usesDurableReceipts: false, env: env ) // Once authenticated, a local decode/quota/save failure is not proof @@ -162,6 +187,11 @@ final class BLEFileTransferHandler { senderNickname: senderNickname, timestamp: timestamp, isPrivate: true, + // Every authenticated Noise private-file keeps the stable ID/ACK + // contract introduced with capability bit 8. Bit 9 advertises + // sender-side automatic retry support; it must not downgrade + // prior iOS clients to random IDs or single-check delivery. + usesDurableReceipts: true, env: env ) } @@ -172,6 +202,7 @@ final class BLEFileTransferHandler { senderNickname: String, timestamp: Date, isPrivate: Bool, + usesDurableReceipts: Bool, env: BLEFileTransferHandlerEnvironment ) -> Bool { @@ -204,7 +235,7 @@ final class BLEFileTransferHandler { return true } - let messageID = isPrivate + let messageID = usesDurableReceipts ? PrivateMediaMessageIdentity.stableID( for: filePacket, senderPeerID: peerID, @@ -212,7 +243,10 @@ final class BLEFileTransferHandler { ) : nil if let messageID { - switch privateMediaArrivals.reserve(messageID) { + switch privateMediaArrivals.reserve( + messageID, + receiptState: { env.privateMediaReceiptState(messageID) } + ) { case .reserved: break case .pending: @@ -224,20 +258,55 @@ final class BLEFileTransferHandler { category: .session ) return true - case .accepted: + case .accepted(let existingFile): env.updatePeerLastSeen(peerID) - env.acknowledgePrivateMediaDuplicate(messageID, peerID) + let message = incomingMessage( + messageID: messageID, + senderNickname: senderNickname, + timestamp: timestamp, + isPrivate: true, + peerID: peerID, + destination: existingFile, + category: storedMediaCategory( + for: existingFile, + fallback: mime.category + ), + env: env + ) SecureLogger.debug( - "📁 Ignored durable private media duplicate id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))…", + "📁 Restored durable private media duplicate id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))… -> \(existingFile.lastPathComponent)", + category: .session + ) + deliverStableMessage( + message, + messageID: messageID, + peerID: peerID, + expectedURL: existingFile, + env: env + ) + return true + case .tombstoned: + // Explicit deletion is a durable terminal receiver decision. + env.updatePeerLastSeen(peerID) + env.acknowledgePrivateMedia(messageID, peerID) + SecureLogger.debug( + "📁 Dropped explicitly deleted private media id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))…", + category: .session + ) + return true + case .unavailable: + // Never turn an unreadable ledger into an empty ledger. The + // sender can retry after the transient storage failure clears. + SecureLogger.warning( + "📁 Withholding private media id=\(messageID.prefix(12))… while durable receipt state is unavailable", category: .session ) return true } } - var acceptedStableMedia = false defer { if let messageID { - privateMediaArrivals.finish(messageID, accepted: acceptedStableMedia) + privateMediaArrivals.finish(messageID) } } @@ -254,14 +323,82 @@ final class BLEFileTransferHandler { return false } + if let messageID, + !env.commitPrivateMediaFile(messageID, destination) { + // A payload without its durable ID mapping cannot safely suppress + // a retry after relaunch. Roll it back and withhold UI/ACK. + env.removeIncomingFile(destination) + return false + } + if isPrivate { env.updatePeerLastSeen(peerID) } - let message = BitchatMessage( + let message = incomingMessage( + messageID: messageID, + senderNickname: senderNickname, + timestamp: timestamp, + isPrivate: isPrivate, + peerID: peerID, + destination: destination, + category: mime.category, + env: env + ) + + SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session) + + if let messageID { + deliverStableMessage( + message, + messageID: messageID, + peerID: peerID, + expectedURL: destination, + env: env + ) + } else { + env.deliverMessage(message, { true }, {}) + } + return true + } + + private func deliverStableMessage( + _ message: BitchatMessage, + messageID: String, + peerID: PeerID, + expectedURL: URL, + env: BLEFileTransferHandlerEnvironment + ) { + env.deliverMessage( + message, + { + guard case .accepted(let resolvedURL) = + env.privateMediaReceiptState(messageID) else { + return false + } + return resolvedURL.standardizedFileURL + == expectedURL.standardizedFileURL + }, + { + env.acknowledgePrivateMedia(messageID, peerID) + } + ) + } + + private func incomingMessage( + messageID: String?, + senderNickname: String, + timestamp: Date, + isPrivate: Bool, + peerID: PeerID, + destination: URL, + category: MimeType.Category, + env: BLEFileTransferHandlerEnvironment + ) -> BitchatMessage { + BitchatMessage( id: messageID, sender: senderNickname, - content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)", + content: "\(category.messagePrefix)\(destination.lastPathComponent)", timestamp: timestamp, isRelay: false, originalSender: nil, @@ -269,18 +406,35 @@ final class BLEFileTransferHandler { recipientNickname: nil, senderPeerID: peerID, // Received messages need an explicit status: BitchatMessage - // defaults private messages to .sending, which the media views - // render as an in-flight send (empty reveal mask, disabled tap). + // defaults private messages to .sending, which media views render + // as an in-flight send. deliveryStatus: isPrivate ? .delivered(to: env.localNickname(), at: timestamp) : nil ) + } - SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session) - - acceptedStableMedia = messageID != nil - env.deliverMessage(message) - return true + /// The durable URL is authoritative during reconstruction. A sender that + /// reuses a stable filename with a different MIME type must not change how + /// the already-stored payload renders. + private func storedMediaCategory( + for url: URL, + fallback: MimeType.Category + ) -> MimeType.Category { + let mediaDirectory = url + .deletingLastPathComponent() + .deletingLastPathComponent() + .lastPathComponent + switch mediaDirectory { + case MimeType.Category.audio.mediaDir: + return .audio + case MimeType.Category.image.mediaDir: + return .image + case MimeType.Category.file.mediaDir: + return .file + default: + return fallback + } } /// Every remaining raw file transfer is signed, regardless of whether it diff --git a/bitchat/Services/BLE/BLEIncomingFileStore.swift b/bitchat/Services/BLE/BLEIncomingFileStore.swift index f7c7703c..a16efa87 100644 --- a/bitchat/Services/BLE/BLEIncomingFileStore.swift +++ b/bitchat/Services/BLE/BLEIncomingFileStore.swift @@ -134,6 +134,7 @@ struct BLEIncomingFileStore { private let baseDirectory: URL? private let dateProvider: () -> Date private let panicMarkerWriter: (Data, URL) throws -> Void + private let privateMediaReceipts: BLEPrivateMediaReceiptStore init( fileManager: FileManager = .default, @@ -147,6 +148,11 @@ struct BLEIncomingFileStore { self.baseDirectory = baseDirectory self.dateProvider = dateProvider self.panicMarkerWriter = panicMarkerWriter + self.privateMediaReceipts = BLEPrivateMediaReceiptStore( + fileManager: fileManager, + baseDirectory: baseDirectory, + now: dateProvider + ) } /// Panic-wipe every managed incoming and outgoing media artifact before @@ -159,6 +165,11 @@ struct BLEIncomingFileStore { func panicWipe( hasDurablePendingMarker: Bool = false ) throws { + // The receipt index caches tombstones as well as accepted payloads. + // Always invalidate it on return, including partial-failure paths, so + // no pre-panic receiver decision survives after identity reset. + defer { privateMediaReceipts.resetForPanic() } + let markerError: Error? do { try markPanicRecoveryPending() @@ -257,6 +268,35 @@ struct BLEIncomingFileStore { } } + func privateMediaReceiptState( + messageID: String + ) -> BLEPrivateMediaReceiptState { + privateMediaReceipts.state(for: messageID) + } + + func commitPrivateMediaFile( + messageID: String, + storedURL: URL + ) -> Bool { + privateMediaReceipts.commitAccepted( + messageID: messageID, + storedURL: storedURL + ) + } + + /// Best-effort rollback for a payload whose durable receipt commit failed. + func removeIncomingFile(at storedURL: URL) { + guard isURLInsideFilesDirectory(storedURL) else { return } + do { + try fileManager.removeItem(at: storedURL) + } catch { + SecureLogger.warning( + "⚠️ Failed to roll back uncommitted incoming media: \(error)", + category: .session + ) + } + } + /// Frees least-recently-modified incoming files until `reservingBytes` /// fits under the quota. Files named `voice_live_*` (in-flight live /// captures) are never evicted regardless of who triggers enforcement — @@ -349,6 +389,13 @@ struct BLEIncomingFileStore { ] } + private func isURLInsideFilesDirectory(_ url: URL) -> Bool { + guard let filesDirectory = try? filesDirectory().standardizedFileURL else { + return false + } + return url.standardizedFileURL.path.hasPrefix(filesDirectory.path + "/") + } + private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String { var candidate = (name ?? "") .replacingOccurrences(of: "\0", with: "") diff --git a/bitchat/Services/BLE/BLEPrivateMediaReceiptStore.swift b/bitchat/Services/BLE/BLEPrivateMediaReceiptStore.swift new file mode 100644 index 00000000..bbbc492c --- /dev/null +++ b/bitchat/Services/BLE/BLEPrivateMediaReceiptStore.swift @@ -0,0 +1,556 @@ +import BitLogger +import Foundation + +enum BLEPrivateMediaReceiptState: Equatable { + /// No durable receiver decision exists for this stable message ID. + case absent + /// The payload is durably mapped to a file that still exists. + case accepted(URL) + /// The user explicitly deleted the payload; retries must not resurrect it. + case tombstoned + /// Durable state could not be read safely. Callers must fail closed and + /// must not save, deliver, or acknowledge the payload. + case unavailable +} + +/// Durable, per-message receiver decisions for stable private media. +/// +/// Each ID has its own atomic record so one hot lookup never rewrites or +/// decodes the entire ledger. The process-lifetime index is installed only +/// after a complete, successful directory scan. An enumeration, read, decode, +/// or structural-validation failure therefore remains retryable and cannot be +/// mistaken for an empty ledger. +final class BLEPrivateMediaReceiptStore: @unchecked Sendable { + typealias DirectoryReader = (_ directory: URL) throws -> [URL] + typealias DataReader = (_ url: URL) throws -> Data + private static let receiptDirectoryName = ".private-media-receipts" + + private struct ReceiptRecord: Codable, Equatable { + enum Kind: String, Codable { + case accepted + case tombstone + } + + let kind: Kind + /// Path below the app's `files/` root. Absolute application-container + /// prefixes are not stable across updates, restores, or reinstalls. + let relativePath: String? + let recordedAt: Date + } + + private final class Runtime: @unchecked Sendable { + let lock = NSLock() + var records: [String: ReceiptRecord]? + var volatileTombstones: [String: Date] = [:] + } + + private let fileManager: FileManager + private let baseDirectory: URL? + private let capacity: Int + private let ttl: TimeInterval + private let now: () -> Date + private let directoryReader: DirectoryReader? + private let dataReader: DataReader? + private let runtime = Runtime() + + init( + fileManager: FileManager = .default, + baseDirectory: URL? = nil, + capacity: Int = TransportConfig.privateMediaReceivedLedgerCapacity, + ttl: TimeInterval = TransportConfig.privateMediaReceivedLedgerTTLSeconds, + now: @escaping () -> Date = Date.init, + directoryReader: DirectoryReader? = nil, + dataReader: DataReader? = nil + ) { + self.fileManager = fileManager + self.baseDirectory = baseDirectory + self.capacity = max(1, capacity) + self.ttl = max(0, ttl) + self.now = now + self.directoryReader = directoryReader + self.dataReader = dataReader + } + + /// Drops process-lifetime decisions after the enclosing media directory + /// has been panic-wiped. A later lookup must rebuild from the durable + /// ledger instead of retaining an accepted receipt or tombstone whose + /// backing files no longer exist. + func resetForPanic() { + runtime.lock.lock() + runtime.records = nil + runtime.volatileTombstones.removeAll(keepingCapacity: false) + runtime.lock.unlock() + } + + func state(for messageID: String) -> BLEPrivateMediaReceiptState { + guard PrivateMediaMessageIdentity.isStableID(messageID) else { + return .absent + } + + runtime.lock.lock() + 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 { + return .unavailable + } + guard let record = records[messageID] else { return .absent } + + if isExpired(record.recordedAt, at: date) { + 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: + guard let relativePath = record.relativePath, + 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. + records.removeValue(forKey: messageID) + runtime.records = records + removeRecord(messageID: messageID, from: directory) + return .absent + } + return .accepted(existingURL) + } + } + + /// Records an accepted ID only after the payload is on disk. Callers must + /// roll the payload back and withhold UI delivery/ACK when this returns + /// false. + func commitAccepted(messageID: String, storedURL: URL) -> Bool { + guard PrivateMediaMessageIdentity.isStableID(messageID), + validExistingPayload(storedURL) != nil, + let relativePath = relativePath(for: storedURL) else { + return false + } + + runtime.lock.lock() + defer { runtime.lock.unlock() } + + let date = now() + if let tombstonedAt = runtime.volatileTombstones[messageID], + !isExpired(tombstonedAt, at: date) { + return false + } + + guard let directory = resolvedReceiptDirectory(), + var records = loadIndexIfNeeded(from: directory, at: date) else { + return false + } + if let existing = records[messageID], + existing.kind == .tombstone, + !isExpired(existing.recordedAt, at: date) { + return false + } + + let victim = capacityVictim( + for: .accepted, + replacing: messageID, + in: records + ) + if records[messageID]?.kind != .accepted, + records.values.lazy.filter({ $0.kind == .accepted }).count >= capacity, + victim == nil { + return false + } + + let record = ReceiptRecord( + kind: .accepted, + relativePath: relativePath, + recordedAt: date + ) + guard persist(record, messageID: messageID, to: directory) else { + return false + } + + records[messageID] = record + if let victim, victim != messageID { + records.removeValue(forKey: victim) + removeRecord(messageID: victim, from: directory) + } + runtime.records = records + 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 + } + + 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) + return false + } + if let existing = records[messageID], + existing.kind == .tombstone, + !isExpired(existing.recordedAt, at: date) { + runtime.volatileTombstones.removeValue(forKey: messageID) + removePayloadRecordedByTombstone(existing) + 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) + 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) + 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) + return true + } + + private func loadIndexIfNeeded( + from directory: URL, + at date: Date + ) -> [String: ReceiptRecord]? { + if let records = runtime.records { + return records + } + + do { + try fileManager.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: nil + ) + } catch { + SecureLogger.error( + "❌ Failed to create private-media receipt directory: \(error)", + category: .session + ) + return nil + } + + let urls: [URL] + do { + if let directoryReader { + urls = try directoryReader(directory) + } else { + urls = try fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil, + options: [] + ) + } + } catch { + SecureLogger.error( + "❌ Failed to enumerate private-media receipts: \(error)", + category: .session + ) + return nil + } + + var records: [String: ReceiptRecord] = [:] + var expired: [String] = [] + var tombstones: [ReceiptRecord] = [] + for url in urls { + guard url.pathExtension == "json" else { continue } + let messageID = url.deletingPathExtension().lastPathComponent + guard PrivateMediaMessageIdentity.isStableID(messageID) else { + continue + } + + let record: ReceiptRecord + do { + let data = try dataReader?(url) ?? Data(contentsOf: url) + record = try JSONDecoder().decode(ReceiptRecord.self, from: data) + } catch { + // Never delete or skip an unreadable stable-ID record. Treating + // it as absent could resurrect accepted or deleted media. + SecureLogger.error( + "❌ Failed to read private-media receipt \(messageID.prefix(12))…: \(error)", + category: .session + ) + return nil + } + + guard isStructurallyValid(record) else { + SecureLogger.error( + "❌ Invalid private-media receipt \(messageID.prefix(12))…", + category: .session + ) + return nil + } + if isExpired(record.recordedAt, at: date) { + expired.append(messageID) + continue + } + records[messageID] = record + if record.kind == .tombstone { + tombstones.append(record) + } + } + + let overflow = overflowVictims(in: records) + for messageID in overflow { + 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 + + for messageID in expired + overflow { + removeRecord(messageID: messageID, from: directory) + } + for tombstone in tombstones { + removePayloadRecordedByTombstone(tombstone) + } + return records + } + + private func isStructurallyValid(_ record: ReceiptRecord) -> Bool { + switch record.kind { + case .tombstone: + guard let relativePath = record.relativePath else { return true } + return candidatePayload(relativePath: relativePath) != nil + case .accepted: + guard let relativePath = record.relativePath else { return false } + return candidatePayload(relativePath: relativePath) != nil + } + } + + private func isExpired(_ recordedAt: Date, at date: Date) -> Bool { + date.timeIntervalSince(recordedAt) > ttl + } + + private func overflowVictims( + in records: [String: ReceiptRecord] + ) -> [String] { + var victims: [String] = [] + for kind in [ReceiptRecord.Kind.accepted, .tombstone] { + let matching = records.filter { $0.value.kind == kind } + let overflow = matching.count - capacity + guard overflow > 0 else { continue } + victims.append(contentsOf: matching.sorted { lhs, rhs in + if lhs.value.recordedAt == rhs.value.recordedAt { + return lhs.key < rhs.key + } + return lhs.value.recordedAt < rhs.value.recordedAt + } + .prefix(overflow) + .map(\.key)) + } + return victims + } + + /// Accepted receipts and tombstones have independent capacity. High media + /// volume cannot evict explicit deletion intent, and vice versa. + private func capacityVictim( + for incomingKind: ReceiptRecord.Kind, + replacing messageID: String, + in records: [String: ReceiptRecord] + ) -> String? { + guard records[messageID]?.kind != incomingKind else { return nil } + let matching = records.filter { + $0.key != messageID && $0.value.kind == incomingKind + } + guard matching.count >= capacity else { return nil } + return matching.min { lhs, rhs in + if lhs.value.recordedAt == rhs.value.recordedAt { + return lhs.key < rhs.key + } + return lhs.value.recordedAt < rhs.value.recordedAt + }?.key + } + + private func persist( + _ record: ReceiptRecord, + messageID: String, + to directory: URL + ) -> Bool { + do { + try fileManager.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: nil + ) + let data = try JSONEncoder().encode(record) + var options: Data.WritingOptions = [.atomic] + #if os(iOS) + options.insert(.completeFileProtectionUntilFirstUserAuthentication) + #endif + let url = recordURL(messageID: messageID, in: directory) + try data.write(to: url, options: options) + return true + } catch { + SecureLogger.error( + "❌ Failed to persist private-media receipt \(messageID.prefix(12))…: \(error)", + category: .session + ) + return false + } + } + + private func removeRecord(messageID: String, from directory: URL) { + let url = recordURL(messageID: messageID, in: directory) + guard fileManager.fileExists(atPath: url.path) else { return } + do { + try fileManager.removeItem(at: url) + } catch { + SecureLogger.warning( + "⚠️ Failed to prune private-media receipt \(messageID.prefix(12))…: \(error)", + category: .session + ) + } + } + + private func recordURL(messageID: String, in directory: URL) -> URL { + directory + .appendingPathComponent(messageID, isDirectory: false) + .appendingPathExtension("json") + } + + private func removePayloadRecordedByTombstone(_ record: ReceiptRecord) { + guard record.kind == .tombstone, + let relativePath = record.relativePath, + let payload = candidatePayload(relativePath: relativePath), + fileManager.fileExists(atPath: payload.path) else { + return + } + do { + try fileManager.removeItem(at: payload) + } catch { + SecureLogger.warning( + "⚠️ Failed to remove explicitly deleted private media: \(error)", + category: .session + ) + } + } + + 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 + } + for (oldMessageID, _) in oldest.prefix(overflow) { + runtime.volatileTombstones.removeValue(forKey: oldMessageID) + } + } + + private func validExistingPayload(_ url: URL) -> URL? { + let standardized = url.standardizedFileURL + guard isInsideFilesDirectory(standardized) else { return nil } + var isDirectory: ObjCBool = false + guard fileManager.fileExists( + atPath: standardized.path, + isDirectory: &isDirectory + ), !isDirectory.boolValue else { + return nil + } + return standardized + } + + private func relativePath(for url: URL) -> String? { + guard let filesRoot = try? filesDirectory().standardizedFileURL else { + return nil + } + let prefix = filesRoot.path + "/" + let standardized = url.standardizedFileURL + guard standardized.path.hasPrefix(prefix) else { return nil } + let relativePath = String(standardized.path.dropFirst(prefix.count)) + return relativePath.isEmpty ? nil : relativePath + } + + private func existingPayload(relativePath: String) -> URL? { + guard let candidate = candidatePayload(relativePath: relativePath) else { + return nil + } + return validExistingPayload(candidate) + } + + private func candidatePayload(relativePath: String) -> URL? { + guard !relativePath.isEmpty, + let filesRoot = try? filesDirectory().standardizedFileURL else { + return nil + } + let candidate = filesRoot + .appendingPathComponent(relativePath, isDirectory: false) + .standardizedFileURL + guard candidate.path.hasPrefix(filesRoot.path + "/") else { return nil } + return candidate + } + + private func isInsideFilesDirectory(_ url: URL) -> Bool { + guard let filesRoot = try? filesDirectory().standardizedFileURL else { + return false + } + return url.standardizedFileURL.path.hasPrefix(filesRoot.path + "/") + } + + private func resolvedReceiptDirectory() -> URL? { + return try? filesDirectory().appendingPathComponent( + Self.receiptDirectoryName, + isDirectory: true + ) + } + + private func filesDirectory() throws -> URL { + let root = try baseDirectory ?? fileManager.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let files = root.appendingPathComponent("files", isDirectory: true) + try fileManager.createDirectory( + at: files, + withIntermediateDirectories: true, + attributes: nil + ) + return files + } +} diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 49b660e7..334a73d9 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -1119,6 +1119,28 @@ final class BLEService: NSObject { collectionsQueue.sync { peerRegistry.capabilities(for: peerID) } } + private func privateMediaPolicyFingerprint( + for peerID: PeerID, + expectedSessionGeneration: UUID? + ) -> String? { + let normalizedPeerID = peerID.toShort() + if let expectedSessionGeneration, + noiseService.sessionGeneration(for: normalizedPeerID) + == expectedSessionGeneration, + let fingerprint = noiseService.getPeerFingerprint(normalizedPeerID), + noiseService.sessionGeneration(for: normalizedPeerID) + == expectedSessionGeneration { + // The exact authenticated Noise static key is stronger than a + // registry entry populated by a public announce. + return fingerprint + } + return collectionsQueue.sync { + peerRegistry.info(for: normalizedPeerID)? + .noisePublicKey? + .sha256Fingerprint() + } + } + func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { let normalizedPeerID = peerID.toShort() let state: ( @@ -1146,7 +1168,10 @@ final class BLEService: NSObject { return .awaitingCapabilityProof } - guard let fingerprint = state.fingerprint else { + guard let fingerprint = privateMediaPolicyFingerprint( + for: normalizedPeerID, + expectedSessionGeneration: state.sessionGeneration + ) ?? state.fingerprint else { // A raw fallback must be bound to the stable Noise key from a // verified registry entry; a routing ID alone can rotate or be // spoofed. Without that key neither proof nor safe migration state @@ -1200,11 +1225,13 @@ final class BLEService: NSObject { return } - let fingerprint: String? = self.collectionsQueue.sync { - self.peerRegistry.info(for: normalizedPeerID)? - .noisePublicKey? - .sha256Fingerprint() + let generation = self.collectionsQueue.sync { + self.privateMediaSessionGenerations[normalizedPeerID] } + let fingerprint = self.privateMediaPolicyFingerprint( + for: normalizedPeerID, + expectedSessionGeneration: generation + ) guard let fingerprint else { self.completePrivateMediaPolicyResolution([completion], with: .blockedDowngrade) return @@ -2527,6 +2554,20 @@ final class BLEService: NSObject { defaultPrefix: defaultPrefix ) }, + privateMediaReceiptState: { [weak self] messageID in + self?.incomingFileStore.privateMediaReceiptState( + messageID: messageID + ) ?? .unavailable + }, + commitPrivateMediaFile: { [weak self] messageID, storedURL in + self?.incomingFileStore.commitPrivateMediaFile( + messageID: messageID, + storedURL: storedURL + ) ?? false + }, + removeIncomingFile: { [weak self] storedURL in + self?.incomingFileStore.removeIncomingFile(at: storedURL) + }, isPrivateMediaSenderBlocked: { [weak self] peerID in guard let self else { return false } let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID) @@ -2541,7 +2582,7 @@ final class BLEService: NSObject { updatePeerLastSeen: { [weak self] peerID in self?.updatePeerLastSeen(peerID) }, - acknowledgePrivateMediaDuplicate: { [weak self] messageID, peerID in + acknowledgePrivateMedia: { [weak self] messageID, peerID in guard let self, let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID), !self.identityManager.isBlocked( @@ -2551,9 +2592,12 @@ final class BLEService: NSObject { } self.sendDeliveryAck(for: messageID, to: peerID) }, - deliverMessage: { [weak self] message in - // Single main-actor hop delivering `.messageReceived`. - self?.emitTransportEvent(.messageReceived(message)) + deliverMessage: { [weak self] message, shouldDeliver, completion in + self?.emitTransportEvent( + .messageReceived(message), + shouldDeliver: shouldDeliver, + completion: completion + ) } ) } @@ -4270,9 +4314,22 @@ extension BLEService { } } - private func emitTransportEvent(_ event: TransportEvent) { + private func emitTransportEvent( + _ event: TransportEvent, + shouldDeliver: (() -> Bool)? = nil, + completion: (() -> Void)? = nil + ) { notifyUI { [weak self] in - _ = self?.deliverTransportEvent(event) + 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?() } } diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 5bb039bb..9fa937cb 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -15,6 +15,13 @@ enum TransportConfig { static let privateMediaCapabilityProofTimeoutSeconds: TimeInterval = 5 static let privateMediaCapabilityProofPendingPeerCap: Int = 64 static let privateMediaCapabilityProofWaitersPerPeerCap: Int = 16 + /// Accepted private-media receipts and explicit-deletion tombstones each + /// receive this independent capacity. + static let privateMediaReceivedLedgerCapacity: Int = 4_096 + /// A bounded retry horizon prevents stable receipt state from growing into + /// permanent application history. + static let privateMediaReceivedLedgerTTLSeconds: TimeInterval = + 7 * 24 * 60 * 60 static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays // Fragment relay TTL in sparse graphs; matches messageTTLDefault so media diff --git a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift index 7390e252..824bae69 100644 --- a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift +++ b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift @@ -8,7 +8,7 @@ // `ChatPrivateConversationCoordinatorContextTests` exemplars. // // Real file/codec work remains covered by `ChatMediaPreparationTests`. These -// tests inject a paused voice-note preparer to exercise cancellation ownership +// tests inject paused media preparers to exercise cancellation ownership // across the detached-preparation/MainActor boundary deterministically. // @@ -502,6 +502,58 @@ struct ChatMediaTransferCoordinatorContextTests { coordinator.cleanupLocalFile(forMessage: message) } + @Test @MainActor + func panicDuringImagePreparationDeletesStaleOutputWithoutSideEffects() async throws { + let context = MockChatMediaTransferContext() + let peerID = PeerID(str: "99aabbccddeeff00") + context.selectedPrivateChatPeer = peerID + let sourceURL = try makeCoordinatorTestImageURL() + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent( + "panic-stale-image-\(UUID().uuidString).jpg" + ) + let preparer = PausedImagePreparer(outputURL: outputURL) + let coordinator = ChatMediaTransferCoordinator( + context: context, + prepareImagePacket: { url in try preparer.prepare(url) } + ) + defer { + preparer.release() + try? FileManager.default.removeItem(at: sourceURL) + try? FileManager.default.removeItem(at: outputURL) + } + + coordinator.sendImage(from: sourceURL) + #expect(await TestHelpers.waitUntil( + { preparer.hasStarted }, + timeout: TestConstants.longTimeout + )) + + DispatchQueue.global(qos: .userInitiated).asyncAfter( + deadline: .now() + .milliseconds(100) + ) { + preparer.release() + } + coordinator.resetForPanic() + + #expect(await TestHelpers.waitUntil( + { preparer.hasFinished }, + timeout: TestConstants.longTimeout + )) + #expect(await TestHelpers.waitUntil( + { !FileManager.default.fileExists(atPath: outputURL.path) }, + timeout: TestConstants.longTimeout + )) + #expect(context.privateChats[peerID]?.isEmpty != false) + #expect(context.appendedPublicMessages.isEmpty) + #expect(context.privateFileSends.isEmpty) + #expect(context.broadcastFileSends.isEmpty) + #expect(context.systemMessages.isEmpty) + #expect(context.deliveryStatusUpdates.isEmpty) + #expect(coordinator.transferIdToMessageIDs.isEmpty) + #expect(coordinator.messageIDToTransferId.isEmpty) + } + @Test @MainActor func cancelVoiceNoteDuringDetachedPreparationCannotSendOrRestoreMapping() async throws { let context = MockChatMediaTransferContext() diff --git a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift index 1663e039..31bea94a 100644 --- a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift +++ b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift @@ -239,10 +239,9 @@ struct ChatTransportEventCoordinatorContextTests { #expect(context.meshDeliveryAcks.isEmpty) // Private goes to the private handler, public to the public handler; - // both get mention checks and haptics. Only current-iOS media with a - // cross-device stable ID and authenticated sender gets the - // durable-arrival acknowledgement. Legacy/Android-style random IDs - // remain transfer-compatible but cannot correlate receipts. + // both get mention checks and haptics. Stable-media ACK authorization + // belongs to BLEFileTransferHandler after its durable commit and this + // synchronous acceptance result, not to the generic UI coordinator. let stableMediaID = "media-\(String(repeating: "a", count: 32))" coordinator.didReceiveMessage(makeMessage( id: stableMediaID, @@ -275,9 +274,7 @@ struct ChatTransportEventCoordinatorContextTests { "pm-missing-sender", "pub" ]) - #expect(context.meshDeliveryAcks.count == 1) - #expect(context.meshDeliveryAcks.first?.messageID == stableMediaID) - #expect(context.meshDeliveryAcks.first?.peerID == peerID) + #expect(context.meshDeliveryAcks.isEmpty) } @Test @MainActor diff --git a/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift b/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift index b226b44a..735ade73 100644 --- a/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift +++ b/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift @@ -682,13 +682,18 @@ struct PrivateMediaEndToEndTests { #expect(!identity.hasObservedPrivateMediaCapability( fingerprint: impostorKey.sha256Fingerprint() )) + #expect(identity.hasObservedPrivateMediaCapability( + fingerprint: bob.noiseStaticPublicKeyData().sha256Fingerprint() + )) alice._test_seedConnectedPeer( bob.myPeerID, nickname: "Bob", capabilities: [], noisePublicKey: impostorKey ) - #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent) + // The exact live Noise identity remains authoritative over a later + // impostor registry rewrite. + #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted) } @Test @@ -1290,6 +1295,8 @@ struct PrivateMediaEndToEndTests { return enumerator.compactMap { item in guard let url = item as? URL, + !url.pathComponents.contains(".private-media-receipts"), + url.lastPathComponent != ".private-media-receipts.json", (try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true else { return nil } diff --git a/bitchatTests/Services/BLEFileTransferHandlerTests.swift b/bitchatTests/Services/BLEFileTransferHandlerTests.swift index 816c8bdf..618aa070 100644 --- a/bitchatTests/Services/BLEFileTransferHandlerTests.swift +++ b/bitchatTests/Services/BLEFileTransferHandlerTests.swift @@ -17,8 +17,12 @@ struct BLEFileTransferHandlerTests { var trackedPackets: [BitchatPacket] = [] var quotaReservations: [Int] = [] var saveCalls: [(data: Data, preferredName: String?, subdirectory: String, fallbackExtension: String?, defaultPrefix: String)] = [] + var receiptStates: [String: BLEPrivateMediaReceiptState] = [:] + var receiptCommits: [(messageID: String, storedURL: URL)] = [] + var receiptCommitSucceeds = true + var removedIncomingFiles: [URL] = [] var lastSeenUpdates: [PeerID] = [] - var duplicateDeliveryAcks: [(messageID: String, peerID: PeerID)] = [] + var deliveryAcks: [(messageID: String, peerID: PeerID)] = [] var deliveredMessages: [BitchatMessage] = [] var saveOverride: (( _ data: Data, @@ -27,6 +31,9 @@ struct BLEFileTransferHandlerTests { _ fallbackExtension: String?, _ defaultPrefix: String ) -> URL?)? + var receiptStateOverride: ((String) -> BLEPrivateMediaReceiptState)? + var receiptCommitOverride: ((String, URL) -> Bool)? + var removeIncomingFileOverride: ((URL) -> Void)? } private let localPeerID = PeerID(str: "0102030405060708") @@ -60,17 +67,39 @@ struct BLEFileTransferHandlerTests { } return recorder.saveResult }, + privateMediaReceiptState: { messageID in + if let receiptStateOverride = recorder.receiptStateOverride { + return receiptStateOverride(messageID) + } + return recorder.receiptStates[messageID] ?? .absent + }, + commitPrivateMediaFile: { messageID, storedURL in + recorder.receiptCommits.append((messageID, storedURL)) + if let receiptCommitOverride = recorder.receiptCommitOverride { + return receiptCommitOverride(messageID, storedURL) + } + guard recorder.receiptCommitSucceeds else { return false } + recorder.receiptStates[messageID] = .accepted(storedURL) + return true + }, + removeIncomingFile: { storedURL in + recorder.removedIncomingFiles.append(storedURL) + recorder.removeIncomingFileOverride?(storedURL) + }, isPrivateMediaSenderBlocked: { peerID in recorder.blockedPeers.contains(peerID) }, updatePeerLastSeen: { peerID in recorder.lastSeenUpdates.append(peerID) }, - acknowledgePrivateMediaDuplicate: { messageID, peerID in - recorder.duplicateDeliveryAcks.append((messageID, peerID)) + acknowledgePrivateMedia: { messageID, peerID in + recorder.deliveryAcks.append((messageID, peerID)) }, - deliverMessage: { message in + deliverMessage: { message, shouldDeliver, completion in + guard shouldDeliver() else { return } recorder.deliveredMessages.append(message) + guard shouldDeliver() else { return } + completion() } ) return BLEFileTransferHandler(environment: environment) @@ -310,7 +339,7 @@ struct BLEFileTransferHandlerTests { } @Test - func decryptedPrivateFileUsesValidationQuotaAndPrivateDeliveryWithoutRawSignature() throws { + func bit8EncryptedPrivateFileKeepsStableIDAndAckWithoutBit9Proof() throws { let recorder = Recorder() recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] let handler = makeHandler(recorder: recorder) @@ -341,6 +370,36 @@ struct BLEFileTransferHandlerTests { recipientPeerID: localPeerID, fileName: fileName )) + #expect(recorder.receiptCommits.count == 1) + #expect(recorder.deliveryAcks.count == 1) + #expect(recorder.deliveryAcks.first?.messageID == recorder.deliveredMessages.first?.id) + } + + @Test + func rawLegacyPrivateFileWithRetryShapedNameNeverUsesReceiptLedger() throws { + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo( + remotePeerID, + nickname: "Alice", + isVerified: true, + signingPublicKey: sampleSigningKey + )] + recorder.signatureVerifies = true + let handler = makeHandler(recorder: recorder) + let content = Data([0xFF, 0xD8, 0xFF, 0xD9]) + let packet = try makeFileTransferPacket( + sender: remotePeerID, + mimeType: "image/jpeg", + content: content, + recipientID: Data(hexString: localPeerID.id), + fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg" + ) + + #expect(handler.handle(packet, from: remotePeerID)) + #expect(recorder.receiptCommits.isEmpty) + #expect(recorder.deliveryAcks.isEmpty) + #expect(recorder.deliveredMessages.count == 1) + #expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false) } @Test @@ -374,7 +433,7 @@ struct BLEFileTransferHandlerTests { } @Test - func repeatedStablePrivateMediaIsAcknowledgedWithoutQuotaOrDiskWork() throws { + func lostCapabilityProofThenStableRetryReusesDurableIDWithoutSecondDiskWrite() throws { let recorder = Recorder() recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] let handler = makeHandler(recorder: recorder) @@ -393,11 +452,15 @@ struct BLEFileTransferHandlerTests { fileName: fileName )) + // First encrypted arrival may precede the sender's authenticated bit-9 + // proof. It still uses the bit-8 stable ID/ACK contract. #expect(handler.handlePrivatePayload( payload, from: remotePeerID, timestamp: Date(timeIntervalSince1970: 1_234) )) + // A later automatic retry after proof must resolve the same durable ID + // rather than create a legacy random-ID bubble. #expect(handler.handlePrivatePayload( payload, from: remotePeerID, @@ -406,11 +469,92 @@ struct BLEFileTransferHandlerTests { #expect(recorder.quotaReservations == [content.count]) #expect(recorder.saveCalls.count == 1) - #expect(recorder.deliveredMessages.count == 1) + // The handler re-offers a durable duplicate so a relaunched UI can + // restore its bubble; the synchronous conversation sink deduplicates. + #expect(recorder.deliveredMessages.count == 2) #expect(recorder.lastSeenUpdates == [remotePeerID, remotePeerID]) - #expect(recorder.duplicateDeliveryAcks.count == 1) - #expect(recorder.duplicateDeliveryAcks.first?.messageID == expectedID) - #expect(recorder.duplicateDeliveryAcks.first?.peerID == remotePeerID) + #expect(recorder.deliveryAcks.count == 2) + #expect(recorder.deliveryAcks.allSatisfy { + $0.messageID == expectedID && $0.peerID == remotePeerID + }) + } + + @Test + func acceptedPrivateMediaAfterRelaunchRedeliversDurableURLBeforeAck() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "private-media-handler-relaunch-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: root) } + let store = BLEIncomingFileStore(baseDirectory: root) + let content = Data([0xFF, 0xD8, 0xFF, 0xD9]) + let file = BitchatFilePacket( + fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg", + fileSize: UInt64(content.count), + mimeType: "image/jpeg", + content: content + ) + let payload = try #require(file.encode()) + + func configure(_ recorder: Recorder) { + recorder.peers = [remotePeerID: makePeerInfo( + remotePeerID, + nickname: "Alice", + isVerified: true + )] + recorder.saveOverride = { + data, + preferredName, + subdirectory, + fallbackExtension, + defaultPrefix in + store.save( + data: data, + preferredName: preferredName, + subdirectory: subdirectory, + fallbackExtension: fallbackExtension, + defaultPrefix: defaultPrefix + ) + } + recorder.receiptStateOverride = { + store.privateMediaReceiptState(messageID: $0) + } + recorder.receiptCommitOverride = { + store.commitPrivateMediaFile(messageID: $0, storedURL: $1) + } + recorder.removeIncomingFileOverride = { + store.removeIncomingFile(at: $0) + } + } + + let first = Recorder() + configure(first) + #expect(makeHandler(recorder: first).handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_234) + )) + let originalMessage = try #require(first.deliveredMessages.first) + #expect(first.deliveryAcks.count == 1) + + // A fresh handler models process relaunch: its in-memory reservation + // cache is empty, so only the durable receipt can suppress disk work. + let relaunched = Recorder() + configure(relaunched) + #expect(makeHandler(recorder: relaunched).handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_235) + )) + + #expect(relaunched.quotaReservations.isEmpty) + #expect(relaunched.saveCalls.isEmpty) + #expect(relaunched.receiptCommits.isEmpty) + #expect(relaunched.deliveredMessages.count == 1) + #expect(relaunched.deliveredMessages.first?.id == originalMessage.id) + #expect(relaunched.deliveredMessages.first?.content == originalMessage.content) + #expect(relaunched.deliveryAcks.count == 1) + #expect(relaunched.deliveryAcks.first?.messageID == originalMessage.id) } @Test @@ -451,7 +595,7 @@ struct BLEFileTransferHandlerTests { )) #expect(nestedResult == true) #expect(recorder.saveCalls.count == 1) - #expect(recorder.duplicateDeliveryAcks.isEmpty) + #expect(recorder.deliveryAcks.isEmpty) #expect(recorder.deliveredMessages.isEmpty) // Failure released the reservation, so the sender's later retry can @@ -462,10 +606,78 @@ struct BLEFileTransferHandlerTests { timestamp: Date(timeIntervalSince1970: 1_236) )) #expect(recorder.saveCalls.count == 2) - #expect(recorder.duplicateDeliveryAcks.isEmpty) + #expect(recorder.deliveryAcks.count == 1) #expect(recorder.deliveredMessages.count == 1) } + @Test + func unavailableDurableReceiptStateWithholdsDiskDeliveryAndAck() throws { + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo( + remotePeerID, + nickname: "Alice", + isVerified: true + )] + let fileName = + "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg" + let messageID = try #require(PrivateMediaMessageIdentity.stableID( + senderPeerID: remotePeerID, + recipientPeerID: localPeerID, + fileName: fileName + )) + recorder.receiptStates[messageID] = .unavailable + let handler = makeHandler(recorder: recorder) + let content = Data([0xFF, 0xD8, 0xFF, 0xD9]) + let payload = try #require(BitchatFilePacket( + fileName: fileName, + fileSize: UInt64(content.count), + mimeType: "image/jpeg", + content: content + ).encode()) + + #expect(handler.handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_234) + )) + #expect(recorder.quotaReservations.isEmpty) + #expect(recorder.saveCalls.isEmpty) + #expect(recorder.receiptCommits.isEmpty) + #expect(recorder.deliveredMessages.isEmpty) + #expect(recorder.deliveryAcks.isEmpty) + } + + @Test + func durableReceiptCommitFailureRollsBackAndWithholdsDeliveryAck() throws { + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo( + remotePeerID, + nickname: "Alice", + isVerified: true + )] + recorder.receiptCommitSucceeds = false + let handler = makeHandler(recorder: recorder) + let content = Data([0xFF, 0xD8, 0xFF, 0xD9]) + let payload = try #require(BitchatFilePacket( + fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg", + fileSize: UInt64(content.count), + mimeType: "image/jpeg", + content: content + ).encode()) + + #expect(!handler.handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_234) + )) + #expect(recorder.saveCalls.count == 1) + #expect(recorder.receiptCommits.count == 1) + #expect(recorder.removedIncomingFiles.count == 1) + #expect(recorder.removedIncomingFiles.first == recorder.saveResult) + #expect(recorder.deliveredMessages.isEmpty) + #expect(recorder.deliveryAcks.isEmpty) + } + @Test func blockedPrivateMediaIsDroppedBeforeQuotaDiskAndDedupState() throws { let recorder = Recorder() @@ -490,7 +702,7 @@ struct BLEFileTransferHandlerTests { #expect(recorder.quotaReservations.isEmpty) #expect(recorder.saveCalls.isEmpty) #expect(recorder.lastSeenUpdates.isEmpty) - #expect(recorder.duplicateDeliveryAcks.isEmpty) + #expect(recorder.deliveryAcks.isEmpty) #expect(recorder.deliveredMessages.isEmpty) // Unblocking must allow a retry through; the blocked attempt cannot @@ -503,6 +715,7 @@ struct BLEFileTransferHandlerTests { )) #expect(recorder.saveCalls.count == 1) #expect(recorder.deliveredMessages.count == 1) + #expect(recorder.deliveryAcks.count == 1) } @Test @@ -524,7 +737,7 @@ struct BLEFileTransferHandlerTests { #expect(recorder.quotaReservations.isEmpty) #expect(recorder.saveCalls.isEmpty) #expect(recorder.lastSeenUpdates.isEmpty) - #expect(recorder.duplicateDeliveryAcks.isEmpty) + #expect(recorder.deliveryAcks.isEmpty) #expect(recorder.deliveredMessages.isEmpty) } @@ -656,6 +869,33 @@ struct BLEFileTransferHandlerTests { } } + @Test + func panicWipeClearsCachedPrivateMediaReceiptDecisions() throws { + let base = FileManager.default.temporaryDirectory + .appendingPathComponent( + "panic-receipt-cache-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: base) } + let messageID = "media-00112233445566778899aabbccddeeff" + + let seed = BLEPrivateMediaReceiptStore(baseDirectory: base) + #expect(seed.recordDeleted(messageID: messageID)) + + let store = BLEIncomingFileStore(baseDirectory: base) + #expect( + store.privateMediaReceiptState(messageID: messageID) + == .tombstoned + ) + + try store.panicWipe() + + #expect( + store.privateMediaReceiptState(messageID: messageID) + == .absent + ) + } + @Test func panicWipeAttemptsDeletionWhenMarkerPersistenceFails() throws { enum MarkerFailure: Error { case unavailable } diff --git a/bitchatTests/Services/BLENoiseReconnectPolicyTests.swift b/bitchatTests/Services/BLENoiseReconnectPolicyTests.swift index fd334d64..8e8f025b 100644 --- a/bitchatTests/Services/BLENoiseReconnectPolicyTests.swift +++ b/bitchatTests/Services/BLENoiseReconnectPolicyTests.swift @@ -111,5 +111,8 @@ struct BLENoiseReconnectPolicyTests { ) ) #expect(PeerCapabilities.localSupported.contains(.privateMedia)) + #expect( + PeerCapabilities.localSupported.contains(.privateMediaReceipts) + ) } } diff --git a/bitchatTests/Services/BLEPrivateMediaReceiptStoreTests.swift b/bitchatTests/Services/BLEPrivateMediaReceiptStoreTests.swift new file mode 100644 index 00000000..9efa71ce --- /dev/null +++ b/bitchatTests/Services/BLEPrivateMediaReceiptStoreTests.swift @@ -0,0 +1,206 @@ +import Foundation +import Testing +@testable import bitchat + +struct BLEPrivateMediaReceiptStoreTests { + private struct TestError: Error {} + + private let messageID = "media-00112233445566778899aabbccddeeff" + + @Test + func acceptedReceiptPersistsAcrossStoreInstances() throws { + let root = makeRoot("persist") + defer { try? FileManager.default.removeItem(at: root) } + let payload = try makePayload(in: root) + + let first = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(first.commitAccepted(messageID: messageID, storedURL: payload)) + #expect(first.state(for: messageID) == .accepted(payload)) + + let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(relaunched.state(for: messageID) == .accepted(payload)) + } + + @Test + func directoryEnumerationFailureIsUnavailableAndRetriesWithoutCachingEmpty() throws { + let root = makeRoot("list-failure") + defer { try? FileManager.default.removeItem(at: root) } + let payload = try makePayload(in: root) + #expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted( + messageID: messageID, + storedURL: payload + )) + let record = receiptRecord(in: root) + #expect(FileManager.default.fileExists(atPath: record.path)) + + var shouldFail = true + let store = BLEPrivateMediaReceiptStore( + baseDirectory: root, + directoryReader: { directory in + if shouldFail { + shouldFail = false + throw TestError() + } + return try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ) + } + ) + + #expect(store.state(for: messageID) == .unavailable) + #expect(FileManager.default.fileExists(atPath: record.path)) + #expect(store.state(for: messageID) == .accepted(payload)) + } + + @Test + func recordReadFailureIsUnavailableAndPreservesReceiptForRetry() throws { + let root = makeRoot("read-failure") + defer { try? FileManager.default.removeItem(at: root) } + let payload = try makePayload(in: root) + #expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted( + messageID: messageID, + storedURL: payload + )) + let record = receiptRecord(in: root) + + var shouldFail = true + let store = BLEPrivateMediaReceiptStore( + baseDirectory: root, + dataReader: { url in + if shouldFail { + shouldFail = false + throw TestError() + } + return try Data(contentsOf: url) + } + ) + + #expect(store.state(for: messageID) == .unavailable) + #expect(FileManager.default.fileExists(atPath: record.path)) + #expect(store.state(for: messageID) == .accepted(payload)) + } + + @Test + func decodeFailureIsUnavailableAndDoesNotDeleteOrCachePastRepair() throws { + let root = makeRoot("decode-failure") + defer { try? FileManager.default.removeItem(at: root) } + let payload = try makePayload(in: root) + #expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted( + messageID: messageID, + storedURL: payload + )) + let record = receiptRecord(in: root) + let durableBytes = try Data(contentsOf: record) + let corruptBytes = Data("{not-json".utf8) + try corruptBytes.write(to: record, options: .atomic) + + let store = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(store.state(for: messageID) == .unavailable) + #expect(!store.commitAccepted(messageID: messageID, storedURL: payload)) + #expect(FileManager.default.fileExists(atPath: record.path)) + #expect(try Data(contentsOf: record) == corruptBytes) + + try durableBytes.write(to: record, options: .atomic) + #expect(store.state(for: messageID) == .accepted(payload)) + } + + @Test + func unreadableTombstoneNeverBecomesAbsentOrGetsDeleted() throws { + let root = makeRoot("tombstone-decode") + 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)) + #expect(seed.recordDeleted(messageID: messageID)) + #expect(!FileManager.default.fileExists(atPath: payload.path)) + + let record = receiptRecord(in: root) + let durableBytes = try Data(contentsOf: record) + try Data([0xFF, 0x00, 0x7B]).write(to: record, options: .atomic) + + let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(relaunched.state(for: messageID) == .unavailable) + #expect(FileManager.default.fileExists(atPath: record.path)) + + try durableBytes.write(to: record, options: .atomic) + #expect(relaunched.state(for: messageID) == .tombstoned) + } + + @Test + func failedTombstonePersistenceDoesNotPoisonVolatileState() throws { + let root = makeRoot("failed-tombstone-write") + 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) + try FileManager.default.createDirectory( + at: record, + withIntermediateDirectories: true + ) + #expect(!store.recordDeleted(messageID: messageID)) + try FileManager.default.removeItem(at: record) + + // The UI must be able to report the deletion failure without a + // process-lifetime tombstone silently hiding a later retry. + #expect(store.state(for: messageID) == .absent) + } + + @Test + func unreleasedAggregateLedgerIsIgnoredAndLeftUntouched() throws { + let root = makeRoot("no-legacy-migration") + defer { try? FileManager.default.removeItem(at: root) } + let files = root.appendingPathComponent("files", isDirectory: true) + try FileManager.default.createDirectory( + at: files, + withIntermediateDirectories: true + ) + let legacy = files.appendingPathComponent( + ".private-media-receipts.json", + isDirectory: false + ) + let bytes = Data( + #"{"entries":{"media-00112233445566778899aabbccddeeff":{"relativePath":"images/incoming/old.jpg","acceptedAt":0}}}"# + .utf8 + ) + try bytes.write(to: legacy, options: .atomic) + + let store = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(store.state(for: messageID) == .absent) + #expect(try Data(contentsOf: legacy) == bytes) + } + + private func makeRoot(_ label: String) -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent( + "private-media-receipt-\(label)-\(UUID().uuidString)", + isDirectory: true + ) + } + + private func makePayload(in root: URL) throws -> URL { + let directory = root.appendingPathComponent( + "files/images/incoming", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + let payload = directory.appendingPathComponent("image.jpg") + try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: payload) + return payload + } + + private func receiptRecord(in root: URL) -> URL { + root + .appendingPathComponent( + "files/.private-media-receipts", + isDirectory: true + ) + .appendingPathComponent(messageID) + .appendingPathExtension("json") + } +} diff --git a/docs/PRIVATE-MEDIA-MIGRATION.md b/docs/PRIVATE-MEDIA-MIGRATION.md index fa0ba2f1..5560f8ce 100644 --- a/docs/PRIVATE-MEDIA-MIGRATION.md +++ b/docs/PRIVATE-MEDIA-MIGRATION.md @@ -28,6 +28,13 @@ peer's Noise session before BLE fragmentation. `0x21`. A valid bit-8 proof selects Noise `0x20`; a valid no-bit proof or a no-proof timeout reaches the explicit legacy-consent path for an unpinned peer. No timeout automatically sends raw bytes. +- `PeerCapabilities.privateMediaReceipts` is bit 9. Its exact-session + authenticated proof enables bounded sender-side automatic retry; a public + announce never does. It does not replace bit 8: encrypted `0x20` media from + bit-8-only prior iOS clients keeps the same deterministic stable ID and + delivery ACK. Receivers durably commit that ID before UI delivery or ACK, so + a lost proof followed by a later bit-9 retry cannot create a second, + random-ID bubble. - An unpinned peer with a stable Noise key but without that capability is eligible for one signed, directed `fileTransfer`, matching the pre-migration wire form used by older iOS and diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift index b0a168da..b1308c9b 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift @@ -28,6 +28,13 @@ public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable { /// before outer BLE fragmentation. Peers that omit this bit require the /// signed directed raw-file migration fallback. public static let privateMedia = PeerCapabilities(rawValue: 1 << 8) + /// Stable private-media IDs are durably deduplicated by the receiver and + /// correlated delivery/read receipts permit bounded automatic resend. + /// + /// Bit 8 remains the encrypted-media compatibility contract. Bit 9 only + /// enables sender-side automatic retry after exact-session proof. + public static let privateMediaReceipts = + PeerCapabilities(rawValue: 1 << 9) /// Reserved for test builds that briefly advertised non-destructive Noise /// replacement. Current clients intentionally do not advertise or act on /// this bit; keep it decodable so the wire assignment is never reused. diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift index 9222e2e6..d66f0cdf 100644 --- a/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift @@ -18,14 +18,30 @@ struct PeerCapabilitiesTests { #expect(PeerCapabilities.meshDiagnostics.encoded() == Data([0x40])) #expect(PeerCapabilities.privateMedia.encoded() == Data([0x00, 0x01])) - let high = PeerCapabilities(rawValue: 1 << 9) - #expect(high.encoded() == Data([0x00, 0x02])) + #expect( + PeerCapabilities.privateMediaReceipts.encoded() + == Data([0x00, 0x02]) + ) #expect( PeerCapabilities.nonDestructiveNoiseReplacement.encoded() == Data([0x00, 0x04]) ) - let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics, .privateMedia] + let high = PeerCapabilities(rawValue: 1 << 11) + #expect(high.encoded() == Data([0x00, 0x08])) + + let all: PeerCapabilities = [ + .prekeys, + .wifiBulk, + .gateway, + .groups, + .board, + .vouch, + .meshDiagnostics, + .privateMedia, + .privateMediaReceipts, + .nonDestructiveNoiseReplacement + ] #expect(PeerCapabilities(encoded: all.encoded()) == all) #expect(PeerCapabilities(encoded: high.encoded()) == high) #expect(PeerCapabilities(encoded: PeerCapabilities([]).encoded()) == [])