mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 01:45:18 +00:00
Invalidate media deletion callbacks during panic
This commit is contained in:
@@ -179,10 +179,21 @@ struct BLEIncomingFileStore: @unchecked Sendable {
|
|||||||
func panicWipe(
|
func panicWipe(
|
||||||
hasDurablePendingMarker: Bool = false
|
hasDurablePendingMarker: Bool = false
|
||||||
) throws {
|
) throws {
|
||||||
// The receipt index caches tombstones as well as accepted payloads.
|
// The receipt index caches tombstones as well as accepted payloads,
|
||||||
// Always invalidate it on return, including partial-failure paths, so
|
// while payload coordination retains save/delete reservations. Always
|
||||||
// no pre-panic receiver decision survives after identity reset.
|
// invalidate both on return, including partial-failure paths, so no
|
||||||
defer { privateMediaReceipts.resetForPanic() }
|
// pre-panic receiver decision survives after identity reset.
|
||||||
|
defer {
|
||||||
|
privateMediaReceipts.resetForPanic()
|
||||||
|
payloadCoordination.lock.lock()
|
||||||
|
payloadCoordination.pendingDeliveryPaths.removeAll(
|
||||||
|
keepingCapacity: false
|
||||||
|
)
|
||||||
|
payloadCoordination.deletionReservations.removeAll(
|
||||||
|
keepingCapacity: false
|
||||||
|
)
|
||||||
|
payloadCoordination.lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
let markerError: Error?
|
let markerError: Error?
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -3492,6 +3492,18 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _test_emitTransportEvent(
|
||||||
|
_ event: TransportEvent,
|
||||||
|
completion: @escaping () -> Void,
|
||||||
|
finalization: @escaping (TransportEventDeliveryOutcome) -> Void
|
||||||
|
) {
|
||||||
|
emitTransportEvent(
|
||||||
|
event,
|
||||||
|
completion: completion,
|
||||||
|
finalization: finalization
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true, signingPublicKey: Data? = nil) {
|
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true, signingPublicKey: Data? = nil) {
|
||||||
if preseedPeer {
|
if preseedPeer {
|
||||||
// Ensure the synthetic peer is known and marked verified for public-message tests
|
// Ensure the synthetic peer is known and marked verified for public-message tests
|
||||||
@@ -4504,11 +4516,24 @@ extension BLEService {
|
|||||||
completion: (() -> Void)? = nil,
|
completion: (() -> Void)? = nil,
|
||||||
finalization: ((TransportEventDeliveryOutcome) -> Void)? = nil
|
finalization: ((TransportEventDeliveryOutcome) -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
notifyUI { [weak self] in
|
guard let generation = capturePanicLifecycleGeneration() else {
|
||||||
|
Task { @MainActor in
|
||||||
|
finalization?(.rejected)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self,
|
||||||
|
self.isCurrentPanicLifecycleGeneration(generation) else {
|
||||||
|
finalization?(.rejected)
|
||||||
|
return
|
||||||
|
}
|
||||||
TransportEventDeliveryGate.attempt(
|
TransportEventDeliveryGate.attempt(
|
||||||
shouldDeliver: { shouldDeliver?() ?? true },
|
shouldDeliver: {
|
||||||
|
self.isCurrentPanicLifecycleGeneration(generation)
|
||||||
|
&& (shouldDeliver?() ?? true)
|
||||||
|
},
|
||||||
deliver: {
|
deliver: {
|
||||||
guard let self else { return .rejected }
|
|
||||||
return self.deliverTransportEvent(event)
|
return self.deliverTransportEvent(event)
|
||||||
},
|
},
|
||||||
completion: { completion?() },
|
completion: { completion?() },
|
||||||
|
|||||||
@@ -359,6 +359,7 @@ final class ChatMediaTransferCoordinator {
|
|||||||
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
|
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
|
||||||
private(set) var messageIDToTransferId: [String: String] = [:]
|
private(set) var messageIDToTransferId: [String: String] = [:]
|
||||||
private var preparationGeneration: UInt64 = 0
|
private var preparationGeneration: UInt64 = 0
|
||||||
|
private var deletionGeneration: UInt64 = 0
|
||||||
private var reconnectRetryRecords: [
|
private var reconnectRetryRecords: [
|
||||||
String: PrivateMediaReconnectRetryRecord
|
String: PrivateMediaReconnectRetryRecord
|
||||||
] = [:]
|
] = [:]
|
||||||
@@ -1009,10 +1010,14 @@ final class ChatMediaTransferCoordinator {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let generation = deletionGeneration
|
||||||
context.persistDeletedPrivateMedia(
|
context.persistDeletedPrivateMedia(
|
||||||
messageIDs: [messageID]
|
messageIDs: [messageID]
|
||||||
) { [weak self] persisted in
|
) { [weak self] persisted in
|
||||||
guard let self else { return }
|
guard let self,
|
||||||
|
self.deletionGeneration == generation else {
|
||||||
|
return
|
||||||
|
}
|
||||||
guard persisted else {
|
guard persisted else {
|
||||||
SecureLogger.error(
|
SecureLogger.error(
|
||||||
"Refusing to delete private media without a durable tombstone id=\(messageID.prefix(12))…",
|
"Refusing to delete private media without a durable tombstone id=\(messageID.prefix(12))…",
|
||||||
@@ -1100,6 +1105,7 @@ final class ChatMediaTransferCoordinator {
|
|||||||
/// can recreate a message or send after a panic wipe.
|
/// can recreate a message or send after a panic wipe.
|
||||||
func resetForPanic() {
|
func resetForPanic() {
|
||||||
preparationGeneration &+= 1
|
preparationGeneration &+= 1
|
||||||
|
deletionGeneration &+= 1
|
||||||
peersResolvingReconnectRetry.removeAll(keepingCapacity: false)
|
peersResolvingReconnectRetry.removeAll(keepingCapacity: false)
|
||||||
for task in reconnectRetryExpiryTasks.values {
|
for task in reconnectRetryExpiryTasks.values {
|
||||||
task.cancel()
|
task.cancel()
|
||||||
|
|||||||
@@ -390,6 +390,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
|||||||
PendingPrivateChatClear
|
PendingPrivateChatClear
|
||||||
] = []
|
] = []
|
||||||
@MainActor private var privateChatClearInFlight = false
|
@MainActor private var privateChatClearInFlight = false
|
||||||
|
@MainActor private var privateChatClearGeneration: UInt64 = 0
|
||||||
private var pendingLegacyPrivateMediaConsents: [PendingLegacyPrivateMediaConsent] = []
|
private var pendingLegacyPrivateMediaConsents: [PendingLegacyPrivateMediaConsent] = []
|
||||||
|
|
||||||
private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) {
|
private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) {
|
||||||
@@ -718,8 +719,15 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
|||||||
}
|
}
|
||||||
privateChatClearInFlight = true
|
privateChatClearInFlight = true
|
||||||
let request = queuedPrivateChatClears.removeFirst()
|
let request = queuedPrivateChatClears.removeFirst()
|
||||||
performPrivateChatClear(request) { [weak self] in
|
let generation = privateChatClearGeneration
|
||||||
guard let self else { return }
|
performPrivateChatClear(
|
||||||
|
request,
|
||||||
|
generation: generation
|
||||||
|
) { [weak self] in
|
||||||
|
guard let self,
|
||||||
|
self.privateChatClearGeneration == generation else {
|
||||||
|
return
|
||||||
|
}
|
||||||
self.privateChatClearInFlight = false
|
self.privateChatClearInFlight = false
|
||||||
self.startNextPrivateChatClearIfNeeded()
|
self.startNextPrivateChatClearIfNeeded()
|
||||||
}
|
}
|
||||||
@@ -728,8 +736,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
|||||||
@MainActor
|
@MainActor
|
||||||
private func performPrivateChatClear(
|
private func performPrivateChatClear(
|
||||||
_ request: PendingPrivateChatClear,
|
_ request: PendingPrivateChatClear,
|
||||||
|
generation: UInt64,
|
||||||
completion: @escaping @MainActor () -> Void
|
completion: @escaping @MainActor () -> Void
|
||||||
) {
|
) {
|
||||||
|
guard privateChatClearGeneration == generation else {
|
||||||
|
completion()
|
||||||
|
return
|
||||||
|
}
|
||||||
let peerID = request.peerID
|
let peerID = request.peerID
|
||||||
let selectedConversationID = request.sourceConversationID
|
let selectedConversationID = request.sourceConversationID
|
||||||
let messagesToClear = request.messages
|
let messagesToClear = request.messages
|
||||||
@@ -816,6 +829,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
|||||||
persisted: Bool,
|
persisted: Bool,
|
||||||
durableStableIDs: Set<String>
|
durableStableIDs: Set<String>
|
||||||
) {
|
) {
|
||||||
|
guard privateChatClearGeneration == generation else {
|
||||||
|
completion()
|
||||||
|
return
|
||||||
|
}
|
||||||
guard persisted else {
|
guard persisted else {
|
||||||
SecureLogger.error(
|
SecureLogger.error(
|
||||||
"Refusing to clear private chat without durable media tombstones peer=\(peerID.id.prefix(8))…",
|
"Refusing to clear private chat without durable media tombstones peer=\(peerID.id.prefix(8))…",
|
||||||
@@ -1537,6 +1554,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
|||||||
// handles before clearing state or removing the media directory.
|
// handles before clearing state or removing the media directory.
|
||||||
mediaTransferCoordinator.resetForPanic()
|
mediaTransferCoordinator.resetForPanic()
|
||||||
liveVoiceCoordinator.resetForPanic()
|
liveVoiceCoordinator.resetForPanic()
|
||||||
|
privateChatClearGeneration &+= 1
|
||||||
|
queuedPrivateChatClears.removeAll(keepingCapacity: false)
|
||||||
|
privateChatClearInFlight = false
|
||||||
|
|
||||||
// Deny and release any clear-media confirmations before identities,
|
// Deny and release any clear-media confirmations before identities,
|
||||||
// message state, and local files are wiped.
|
// message state, and local files are wiped.
|
||||||
|
|||||||
@@ -932,6 +932,44 @@ struct BLEServiceCoreTests {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func panicSuspension_finalizesStaleTransportEventsAsRejected() async {
|
||||||
|
let ble = makeService()
|
||||||
|
let delegate = TransportEventCaptureDelegate()
|
||||||
|
ble.eventDelegate = delegate
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: "pre-panic-finalization",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "must be rejected",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: "Me",
|
||||||
|
senderPeerID: PeerID(str: "1122334455667788")
|
||||||
|
)
|
||||||
|
var completions = 0
|
||||||
|
var outcomes: [TransportEventDeliveryOutcome] = []
|
||||||
|
|
||||||
|
ble._test_emitTransportEvent(
|
||||||
|
.messageReceived(message),
|
||||||
|
completion: { completions += 1 },
|
||||||
|
finalization: { outcomes.append($0) }
|
||||||
|
)
|
||||||
|
ble.suspendForPanicReset()
|
||||||
|
ble._test_emitTransportEvent(
|
||||||
|
.messageReceived(message),
|
||||||
|
completion: { completions += 1 },
|
||||||
|
finalization: { outcomes.append($0) }
|
||||||
|
)
|
||||||
|
for _ in 0..<4 {
|
||||||
|
await Task.yield()
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(delegate.messageIDs.isEmpty)
|
||||||
|
#expect(completions == 0)
|
||||||
|
#expect(outcomes == [.rejected, .rejected])
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func modifiedServices_rediscoverWhenBitChatServiceIsInvalidated() async throws {
|
func modifiedServices_rediscoverWhenBitChatServiceIsInvalidated() async throws {
|
||||||
let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB")
|
let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB")
|
||||||
|
|||||||
@@ -573,6 +573,24 @@ struct ChatMediaTransferCoordinatorContextTests {
|
|||||||
#expect(coordinator.messageIDToTransferId[messageID] == nil)
|
#expect(coordinator.messageIDToTransferId[messageID] == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func deleteIncomingStableMediaCompletionAfterPanicIsIgnored() {
|
||||||
|
let context = MockChatMediaTransferContext()
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||||
|
let messageID = "media-11223344556677889900aabbccddeeff"
|
||||||
|
context.requiredTombstoneIDs = [messageID]
|
||||||
|
context.deferDeletedMediaPersistence = true
|
||||||
|
|
||||||
|
coordinator.deleteMediaMessage(messageID: messageID)
|
||||||
|
#expect(context.persistedDeletionBatches == [[messageID]])
|
||||||
|
|
||||||
|
coordinator.resetForPanic()
|
||||||
|
context.resolveNextDeletionPersistence(true)
|
||||||
|
|
||||||
|
#expect(context.removedMessages.isEmpty)
|
||||||
|
#expect(context.untombstonedMediaRemovals.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func deleteIncomingStableMediaPreservesStateWhenTombstoneFails() {
|
func deleteIncomingStableMediaPreservesStateWhenTombstoneFails() {
|
||||||
let context = MockChatMediaTransferContext()
|
let context = MockChatMediaTransferContext()
|
||||||
|
|||||||
@@ -1864,6 +1864,68 @@ struct ChatViewModelPrivateMediaDeletionTests {
|
|||||||
#expect((viewModel.privateChats[aliasPeerID] ?? []).isEmpty)
|
#expect((viewModel.privateChats[aliasPeerID] ?? []).isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func panicInvalidatesActiveAndQueuedPrivateChatClears() {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
transport.deferDeletedPrivateMediaPersistence = true
|
||||||
|
let firstPeerID = PeerID(str: String(repeating: "4", count: 64))
|
||||||
|
let secondPeerID = PeerID(str: String(repeating: "5", count: 64))
|
||||||
|
let firstID = "media-\(String(repeating: "6", count: 32))"
|
||||||
|
let secondID = "media-\(String(repeating: "7", count: 32))"
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: firstID,
|
||||||
|
sender: "First",
|
||||||
|
senderPeerID: firstPeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "first-pre-panic.jpg"
|
||||||
|
)
|
||||||
|
], for: firstPeerID)
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: secondID,
|
||||||
|
sender: "Second",
|
||||||
|
senderPeerID: secondPeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "second-pre-panic.jpg"
|
||||||
|
)
|
||||||
|
], for: secondPeerID)
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(firstPeerID)
|
||||||
|
viewModel.clearPrivateChat(secondPeerID)
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaMessageIDBatches == [[firstID]]
|
||||||
|
)
|
||||||
|
|
||||||
|
_ = viewModel.panicClearAllData(restartServices: false)
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: firstID,
|
||||||
|
sender: "First",
|
||||||
|
senderPeerID: firstPeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "first-post-panic.jpg"
|
||||||
|
)
|
||||||
|
], for: firstPeerID)
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: secondID,
|
||||||
|
sender: "Second",
|
||||||
|
senderPeerID: secondPeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "second-post-panic.jpg"
|
||||||
|
)
|
||||||
|
], for: secondPeerID)
|
||||||
|
|
||||||
|
transport.resolveNextDeletedPrivateMediaPersistence(true)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaMessageIDBatches == [[firstID]]
|
||||||
|
)
|
||||||
|
#expect(viewModel.privateChats[firstPeerID]?.map(\.id) == [firstID])
|
||||||
|
#expect(viewModel.privateChats[secondPeerID]?.map(\.id) == [secondID])
|
||||||
|
}
|
||||||
|
|
||||||
private func privateMediaMessage(
|
private func privateMediaMessage(
|
||||||
id: String,
|
id: String,
|
||||||
sender: String,
|
sender: String,
|
||||||
|
|||||||
@@ -1122,6 +1122,52 @@ struct BLEFileTransferHandlerTests {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func panicWipeInvalidatesPayloadCoordinationReservations() throws {
|
||||||
|
let base = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(
|
||||||
|
"panic-payload-coordination-\(UUID().uuidString)",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
defer { try? FileManager.default.removeItem(at: base) }
|
||||||
|
let store = BLEIncomingFileStore(baseDirectory: base)
|
||||||
|
let pendingName = "pending-before-panic.jpg"
|
||||||
|
let pendingURL = try #require(store.save(
|
||||||
|
data: Data("old".utf8),
|
||||||
|
preferredName: pendingName,
|
||||||
|
subdirectory: "images/incoming",
|
||||||
|
fallbackExtension: "jpg",
|
||||||
|
defaultPrefix: "image"
|
||||||
|
))
|
||||||
|
let messageID = "media-aabbccddeeff00112233445566778899"
|
||||||
|
let reservation = try #require(store.reservePrivateMediaDeletion(
|
||||||
|
messageIDs: [messageID],
|
||||||
|
payloadRelativePaths: [
|
||||||
|
messageID: "images/incoming/delete-before-panic.jpg"
|
||||||
|
]
|
||||||
|
))
|
||||||
|
|
||||||
|
try store.panicWipe()
|
||||||
|
|
||||||
|
#expect(!store.commitPrivateMediaDeletion(
|
||||||
|
reservation: reservation,
|
||||||
|
messageIDs: [messageID],
|
||||||
|
payloadRelativePaths: [
|
||||||
|
messageID: "images/incoming/delete-before-panic.jpg"
|
||||||
|
],
|
||||||
|
protectedPayloadRelativePaths: []
|
||||||
|
))
|
||||||
|
let postPanicURL = try #require(store.save(
|
||||||
|
data: Data("new".utf8),
|
||||||
|
preferredName: pendingName,
|
||||||
|
subdirectory: "images/incoming",
|
||||||
|
fallbackExtension: "jpg",
|
||||||
|
defaultPrefix: "image"
|
||||||
|
))
|
||||||
|
#expect(pendingURL.lastPathComponent == pendingName)
|
||||||
|
#expect(postPanicURL.lastPathComponent == pendingName)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func panicWipeAttemptsDeletionWhenMarkerPersistenceFails() throws {
|
func panicWipeAttemptsDeletionWhenMarkerPersistenceFails() throws {
|
||||||
enum MarkerFailure: Error { case unavailable }
|
enum MarkerFailure: Error { case unavailable }
|
||||||
|
|||||||
Reference in New Issue
Block a user