mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 23:05:20 +00:00
1311 lines
53 KiB
Swift
1311 lines
53 KiB
Swift
import BitFoundation
|
|
import Foundation
|
|
import Testing
|
|
@testable import bitchat
|
|
|
|
struct BLEFileTransferHandlerTests {
|
|
private final class Recorder {
|
|
var localNickname = "Me"
|
|
var peers: [PeerID: BLEPeerInfo] = [:]
|
|
var signedName: String?
|
|
var signatureVerifies = false
|
|
var saveResult: URL? = URL(fileURLWithPath: "/tmp/files/incoming/sample.pdf")
|
|
|
|
var signatureVerifyCount = 0
|
|
var signedNameQueries: [PeerID] = []
|
|
var blockedPeers: Set<PeerID> = []
|
|
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 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?,
|
|
_ subdirectory: String,
|
|
_ fallbackExtension: String?,
|
|
_ defaultPrefix: String
|
|
) -> URL?)?
|
|
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 },
|
|
localNickname: { recorder.localNickname },
|
|
peersSnapshot: { recorder.peers },
|
|
verifyPacketSignature: { _, _ in
|
|
recorder.signatureVerifyCount += 1
|
|
return recorder.signatureVerifies
|
|
},
|
|
localSigningPublicKey: { [sampleSigningKey] in sampleSigningKey },
|
|
signedSenderDisplayName: { _, peerID in
|
|
recorder.signedNameQueries.append(peerID)
|
|
return recorder.signedName
|
|
},
|
|
trackPacketSeen: { packet in
|
|
recorder.trackedPackets.append(packet)
|
|
},
|
|
enforceStorageQuota: { reservingBytes in
|
|
recorder.quotaReservations.append(reservingBytes)
|
|
},
|
|
saveIncomingFile: { data, preferredName, subdirectory, fallbackExtension, defaultPrefix in
|
|
recorder.saveCalls.append((data, preferredName, subdirectory, fallbackExtension, defaultPrefix))
|
|
if let saveOverride = recorder.saveOverride {
|
|
return saveOverride(data, preferredName, subdirectory, fallbackExtension, defaultPrefix)
|
|
}
|
|
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)
|
|
},
|
|
finishIncomingFileDelivery: { storedURL in
|
|
recorder.finishedIncomingFileDeliveries.append(storedURL)
|
|
recorder.finishIncomingFileDeliveryOverride?(storedURL)
|
|
},
|
|
isPrivateMediaSenderBlocked: { peerID in
|
|
recorder.blockedPeers.contains(peerID)
|
|
},
|
|
updatePeerLastSeen: { peerID in
|
|
recorder.lastSeenUpdates.append(peerID)
|
|
},
|
|
acknowledgePrivateMedia: { messageID, peerID in
|
|
recorder.deliveryAcks.append((messageID, peerID))
|
|
},
|
|
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()
|
|
}
|
|
)
|
|
return BLEFileTransferHandler(environment: environment)
|
|
}
|
|
|
|
@Test
|
|
func broadcastFileFromVerifiedPeerIsSavedAndDelivered() 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("%PDF-1.7".utf8)
|
|
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: content)
|
|
|
|
#expect(handler.handle(packet, from: remotePeerID))
|
|
|
|
#expect(recorder.signatureVerifyCount == 1)
|
|
#expect(recorder.signedNameQueries.isEmpty)
|
|
#expect(recorder.trackedPackets.count == 1)
|
|
#expect(recorder.quotaReservations == [content.count])
|
|
#expect(recorder.saveCalls.count == 1)
|
|
#expect(recorder.saveCalls.first?.data == content)
|
|
#expect(recorder.saveCalls.first?.preferredName == "sample")
|
|
#expect(recorder.saveCalls.first?.subdirectory == "files/incoming")
|
|
#expect(recorder.saveCalls.first?.fallbackExtension == "pdf")
|
|
#expect(recorder.saveCalls.first?.defaultPrefix == "file")
|
|
#expect(recorder.lastSeenUpdates.isEmpty)
|
|
#expect(recorder.deliveredMessages.count == 1)
|
|
let message = recorder.deliveredMessages.first
|
|
#expect(message?.sender == "Alice")
|
|
#expect(message?.content == "[file] sample.pdf")
|
|
#expect(message?.isPrivate == false)
|
|
#expect(message?.senderPeerID == remotePeerID)
|
|
#expect(message?.timestamp == Date(timeIntervalSince1970: 900))
|
|
#expect(message?.deliveryStatus == nil)
|
|
}
|
|
|
|
@Test
|
|
func selfEchoIsDropped() throws {
|
|
let recorder = Recorder()
|
|
recorder.signatureVerifies = true
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = try makeFileTransferPacket(sender: localPeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8), ttl: 3)
|
|
|
|
#expect(!handler.handle(packet, from: localPeerID))
|
|
|
|
expectNoSideEffects(recorder)
|
|
}
|
|
|
|
@Test
|
|
func unknownPeerWithoutValidSignatureIsDropped() throws {
|
|
let recorder = Recorder()
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8))
|
|
|
|
#expect(!handler.handle(packet, from: remotePeerID))
|
|
|
|
#expect(recorder.signedNameQueries == [remotePeerID])
|
|
#expect(recorder.trackedPackets.isEmpty)
|
|
#expect(recorder.deliveredMessages.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func broadcastFromConnectedUnverifiedPeerWithoutSignatureIsDropped() throws {
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = try makeFileTransferPacket(
|
|
sender: remotePeerID,
|
|
mimeType: "application/pdf",
|
|
content: Data("%PDF-1.7".utf8),
|
|
hasSignature: false
|
|
)
|
|
|
|
// Failed sender authentication must also stop the packet from being
|
|
// relayed to downstream nodes.
|
|
#expect(!handler.handle(packet, from: remotePeerID))
|
|
|
|
// Broadcast files carry an attacker-controllable senderID, so — like
|
|
// public messages — a connected-but-unverified peer must present a valid
|
|
// packet signature. No signing key + no signed identity means dropped.
|
|
#expect(recorder.signedNameQueries.isEmpty)
|
|
#expect(recorder.trackedPackets.isEmpty)
|
|
#expect(recorder.deliveredMessages.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func broadcastFromConnectedUnverifiedPeerWithSignedIdentityIsAccepted() throws {
|
|
let recorder = Recorder()
|
|
// Connected but nickname not yet verified and no registry signing key —
|
|
// the persisted-identity signature lookup still authenticates the
|
|
// sender, so the transfer is accepted under that verified name.
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
|
|
recorder.signedName = "Bob"
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8))
|
|
|
|
#expect(handler.handle(packet, from: remotePeerID))
|
|
|
|
#expect(recorder.signedNameQueries == [remotePeerID])
|
|
#expect(recorder.deliveredMessages.count == 1)
|
|
#expect(recorder.deliveredMessages.first?.sender == "Bob")
|
|
}
|
|
|
|
@Test
|
|
func signedSelfBroadcastReplayIsDelivered() throws {
|
|
// Our own broadcast file replayed via gossip sync arrives with ttl==0;
|
|
// it is verified against our local signing key before delivery.
|
|
let recorder = Recorder()
|
|
recorder.signatureVerifies = true
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = try makeFileTransferPacket(
|
|
sender: localPeerID,
|
|
mimeType: "application/pdf",
|
|
content: Data("%PDF-1.7".utf8),
|
|
ttl: 0
|
|
)
|
|
|
|
#expect(handler.handle(packet, from: localPeerID))
|
|
|
|
#expect(recorder.signatureVerifyCount == 1)
|
|
#expect(recorder.signedNameQueries.isEmpty)
|
|
#expect(recorder.deliveredMessages.count == 1)
|
|
#expect(recorder.deliveredMessages.first?.sender == "Me")
|
|
}
|
|
|
|
@Test
|
|
func broadcastFromPeerNotInRegistryAcceptedViaSignedIdentity() throws {
|
|
let recorder = Recorder()
|
|
recorder.signedName = "Carol"
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8))
|
|
|
|
#expect(handler.handle(packet, from: remotePeerID))
|
|
|
|
// Peer absent from the registry: fall back to the persisted-identity
|
|
// signature lookup (mirrors BLEPublicMessageHandler).
|
|
#expect(recorder.signedNameQueries == [remotePeerID])
|
|
#expect(recorder.deliveredMessages.count == 1)
|
|
#expect(recorder.deliveredMessages.first?.sender == "Carol")
|
|
}
|
|
|
|
@Test
|
|
func spoofedBroadcastVoiceNoteWithoutSignatureIsDropped() throws {
|
|
// Regression for the PR #1406 finding: an in-range peer that observed a
|
|
// public voice burst tries to overwrite the live bubble by broadcasting
|
|
// a `voice_<burstID>.m4a` note under the talker's senderID. Without a
|
|
// valid signature the note never reaches the coordinator's absorption.
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Mallory", isVerified: false, isConnected: true)]
|
|
let handler = makeHandler(recorder: recorder)
|
|
let m4a = Data([0x00, 0x00, 0x00, 0x18]) + Data("ftypM4A ".utf8)
|
|
let packet = try makeFileTransferPacket(
|
|
sender: remotePeerID,
|
|
mimeType: "audio/mp4",
|
|
content: m4a,
|
|
fileName: "voice_1122334455667788",
|
|
hasSignature: false
|
|
)
|
|
|
|
// The spoofed note must be dropped locally AND not relayed onward.
|
|
#expect(!handler.handle(packet, from: remotePeerID))
|
|
|
|
#expect(recorder.deliveredMessages.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func rawDirectedFileWithoutVerifiableSignatureIsDroppedWithoutWriteOrRelay() throws {
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = try makeFileTransferPacket(
|
|
sender: remotePeerID,
|
|
mimeType: "application/pdf",
|
|
content: Data("%PDF-1.7".utf8),
|
|
recipientID: Data(hexString: localPeerID.id),
|
|
hasSignature: false
|
|
)
|
|
|
|
#expect(!handler.handle(packet, from: remotePeerID))
|
|
|
|
#expect(recorder.signatureVerifyCount == 0)
|
|
#expect(recorder.signedNameQueries.isEmpty)
|
|
#expect(recorder.trackedPackets.isEmpty)
|
|
#expect(recorder.quotaReservations.isEmpty)
|
|
#expect(recorder.saveCalls.isEmpty)
|
|
#expect(recorder.deliveredMessages.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func fileDirectedToAnotherPeerIsIgnored() throws {
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)]
|
|
recorder.signatureVerifies = true
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = try makeFileTransferPacket(
|
|
sender: remotePeerID,
|
|
mimeType: "application/pdf",
|
|
content: Data("%PDF-1.7".utf8),
|
|
recipientID: Data(hexString: "AABBCCDDEEFF0011")
|
|
)
|
|
|
|
// Not for us, but it must keep relaying toward the real recipient.
|
|
#expect(handler.handle(packet, from: remotePeerID))
|
|
|
|
#expect(recorder.trackedPackets.isEmpty)
|
|
#expect(recorder.quotaReservations.isEmpty)
|
|
#expect(recorder.saveCalls.isEmpty)
|
|
#expect(recorder.deliveredMessages.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func privateFileUpdatesLastSeenAndDeliversPrivateMessage() throws {
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)]
|
|
recorder.signatureVerifies = true
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = try makeFileTransferPacket(
|
|
sender: remotePeerID,
|
|
mimeType: "application/pdf",
|
|
content: Data("%PDF-1.7".utf8),
|
|
recipientID: Data(hexString: localPeerID.id)
|
|
)
|
|
|
|
#expect(handler.handle(packet, from: remotePeerID))
|
|
|
|
// Directed transfers are not tracked for gossip sync.
|
|
#expect(recorder.trackedPackets.isEmpty)
|
|
#expect(recorder.lastSeenUpdates == [remotePeerID])
|
|
#expect(recorder.deliveredMessages.count == 1)
|
|
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
|
#expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false)
|
|
// Must be explicit: BitchatMessage defaults private messages to
|
|
// .sending, which the media views render as an in-flight send
|
|
// (empty reveal mask, disabled reveal tap).
|
|
#expect(recorder.deliveredMessages.first?.deliveryStatus == .delivered(to: "Me", at: Date(timeIntervalSince1970: 900)))
|
|
}
|
|
|
|
@Test
|
|
func bit8EncryptedPrivateFileKeepsStableIDAndAckWithoutBit9Proof() throws {
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
|
let handler = makeHandler(recorder: recorder)
|
|
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
|
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 timestamp = Date(timeIntervalSince1970: 1_234)
|
|
|
|
#expect(handler.handlePrivatePayload(payload, from: remotePeerID, timestamp: timestamp))
|
|
|
|
#expect(recorder.signatureVerifyCount == 0)
|
|
#expect(recorder.signedNameQueries.isEmpty)
|
|
#expect(recorder.trackedPackets.isEmpty)
|
|
#expect(recorder.quotaReservations == [content.count])
|
|
#expect(recorder.saveCalls.first?.data == content)
|
|
#expect(recorder.lastSeenUpdates == [remotePeerID])
|
|
#expect(recorder.deliveredMessages.count == 1)
|
|
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
|
#expect(recorder.deliveredMessages.first?.timestamp == timestamp)
|
|
#expect(recorder.deliveredMessages.first?.id == PrivateMediaMessageIdentity.stableID(
|
|
senderPeerID: remotePeerID,
|
|
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 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()
|
|
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
|
|
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()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
|
let handler = makeHandler(recorder: recorder)
|
|
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
|
let file = BitchatFilePacket(
|
|
fileName: "photo.jpg",
|
|
fileSize: UInt64(content.count),
|
|
mimeType: "image/jpeg",
|
|
content: content
|
|
)
|
|
let payload = try #require(file.encode())
|
|
|
|
#expect(handler.handlePrivatePayload(
|
|
payload,
|
|
from: remotePeerID,
|
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
|
))
|
|
#expect(handler.handlePrivatePayload(
|
|
payload,
|
|
from: remotePeerID,
|
|
timestamp: Date(timeIntervalSince1970: 1_235)
|
|
))
|
|
|
|
#expect(recorder.deliveredMessages.count == 2)
|
|
#expect(recorder.deliveredMessages[0].id != recorder.deliveredMessages[1].id)
|
|
#expect(recorder.deliveredMessages.allSatisfy { !$0.id.hasPrefix("media-") })
|
|
}
|
|
|
|
@Test
|
|
func lostCapabilityProofThenStableRetryReusesDurableIDWithoutSecondDiskWrite() throws {
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
|
let handler = makeHandler(recorder: recorder)
|
|
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
|
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 expectedID = try #require(PrivateMediaMessageIdentity.stableID(
|
|
senderPeerID: remotePeerID,
|
|
recipientPeerID: localPeerID,
|
|
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,
|
|
timestamp: Date(timeIntervalSince1970: 1_235)
|
|
))
|
|
|
|
#expect(recorder.quotaReservations == [content.count])
|
|
#expect(recorder.saveCalls.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.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)
|
|
}
|
|
recorder.finishIncomingFileDeliveryOverride = {
|
|
store.finishIncomingFileDelivery(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
|
|
func inFlightStableDuplicateIsNotAcknowledgedAndFailedSaveRemainsRetryable() throws {
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
|
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
|
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())
|
|
var handler: BLEFileTransferHandler!
|
|
var nestedResult: Bool?
|
|
var failFirstSave = true
|
|
recorder.saveOverride = { _, _, _, _, _ in
|
|
if failFirstSave {
|
|
failFirstSave = false
|
|
nestedResult = handler.handlePrivatePayload(
|
|
payload,
|
|
from: self.remotePeerID,
|
|
timestamp: Date(timeIntervalSince1970: 1_235)
|
|
)
|
|
return nil
|
|
}
|
|
return recorder.saveResult
|
|
}
|
|
handler = makeHandler(recorder: recorder)
|
|
|
|
// The nested arrival sees the first reservation as pending. It is
|
|
// coalesced without an ACK; then the first durable save fails.
|
|
#expect(!handler.handlePrivatePayload(
|
|
payload,
|
|
from: remotePeerID,
|
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
|
))
|
|
#expect(nestedResult == true)
|
|
#expect(recorder.saveCalls.count == 1)
|
|
#expect(recorder.deliveryAcks.isEmpty)
|
|
#expect(recorder.deliveredMessages.isEmpty)
|
|
|
|
// Failure released the reservation, so the sender's later retry can
|
|
// persist and deliver normally.
|
|
#expect(handler.handlePrivatePayload(
|
|
payload,
|
|
from: remotePeerID,
|
|
timestamp: Date(timeIntervalSince1970: 1_236)
|
|
))
|
|
#expect(recorder.saveCalls.count == 2)
|
|
#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()
|
|
recorder.blockedPeers = [remotePeerID]
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
|
let handler = makeHandler(recorder: recorder)
|
|
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
|
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())
|
|
|
|
#expect(handler.handlePrivatePayload(
|
|
payload,
|
|
from: remotePeerID,
|
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
|
))
|
|
|
|
#expect(recorder.quotaReservations.isEmpty)
|
|
#expect(recorder.saveCalls.isEmpty)
|
|
#expect(recorder.lastSeenUpdates.isEmpty)
|
|
#expect(recorder.deliveryAcks.isEmpty)
|
|
#expect(recorder.deliveredMessages.isEmpty)
|
|
|
|
// Unblocking must allow a retry through; the blocked attempt cannot
|
|
// poison the stable-ID dedup reservation.
|
|
recorder.blockedPeers = []
|
|
#expect(handler.handlePrivatePayload(
|
|
payload,
|
|
from: remotePeerID,
|
|
timestamp: Date(timeIntervalSince1970: 1_235)
|
|
))
|
|
#expect(recorder.saveCalls.count == 1)
|
|
#expect(recorder.deliveredMessages.count == 1)
|
|
#expect(recorder.deliveryAcks.count == 1)
|
|
}
|
|
|
|
@Test
|
|
func decryptedPrivateFileOverPayloadCapIsRejectedBeforeQuotaOrDiskWrite() {
|
|
let recorder = Recorder()
|
|
let handler = makeHandler(recorder: recorder)
|
|
let oversizedCount = FileTransferLimits.maxPayloadBytes + 1
|
|
var length = UInt32(oversizedCount).bigEndian
|
|
var payload = Data([0x04]) // BitchatFilePacket CONTENT TLV
|
|
withUnsafeBytes(of: &length) { payload.append(contentsOf: $0) }
|
|
payload.append(Data(repeating: 0x41, count: oversizedCount))
|
|
|
|
#expect(!handler.handlePrivatePayload(
|
|
payload,
|
|
from: remotePeerID,
|
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
|
))
|
|
|
|
#expect(recorder.quotaReservations.isEmpty)
|
|
#expect(recorder.saveCalls.isEmpty)
|
|
#expect(recorder.lastSeenUpdates.isEmpty)
|
|
#expect(recorder.deliveryAcks.isEmpty)
|
|
#expect(recorder.deliveredMessages.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func malformedPayloadIsTrackedForSyncButDropped() {
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)]
|
|
recorder.signatureVerifies = true
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = BitchatPacket(
|
|
type: MessageType.fileTransfer.rawValue,
|
|
senderID: Data(hexString: remotePeerID.id) ?? Data(),
|
|
recipientID: nil,
|
|
timestamp: 900_000,
|
|
payload: Data([0x01, 0x02, 0x03]),
|
|
signature: Data(repeating: 0x5A, count: 64),
|
|
ttl: TransportConfig.messageTTLDefault
|
|
)
|
|
|
|
// Local decode failures are not proof of forgery; the packet stays relayable.
|
|
#expect(handler.handle(packet, from: remotePeerID))
|
|
|
|
// Sync tracking happens before payload validation, matching the original order.
|
|
#expect(recorder.trackedPackets.count == 1)
|
|
#expect(recorder.quotaReservations.isEmpty)
|
|
#expect(recorder.saveCalls.isEmpty)
|
|
#expect(recorder.deliveredMessages.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func unsupportedMimeIsDroppedBeforeQuotaAndSave() throws {
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)]
|
|
recorder.signatureVerifies = true
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: nil, content: Data([0x4D, 0x5A, 0x00, 0x00]))
|
|
|
|
#expect(handler.handle(packet, from: remotePeerID))
|
|
|
|
#expect(recorder.trackedPackets.count == 1)
|
|
#expect(recorder.quotaReservations.isEmpty)
|
|
#expect(recorder.saveCalls.isEmpty)
|
|
#expect(recorder.deliveredMessages.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func saveFailureSkipsDelivery() throws {
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)]
|
|
recorder.signatureVerifies = true
|
|
recorder.saveResult = nil
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8))
|
|
|
|
// A local save failure must not stop the mesh relay.
|
|
#expect(handler.handle(packet, from: remotePeerID))
|
|
|
|
#expect(recorder.quotaReservations.count == 1)
|
|
#expect(recorder.saveCalls.count == 1)
|
|
#expect(recorder.lastSeenUpdates.isEmpty)
|
|
#expect(recorder.deliveredMessages.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func quotaEvictionForFinalizedArrivalSkipsInFlightLiveCaptures() throws {
|
|
let base = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("quota-live-capture-\(UUID().uuidString)", isDirectory: true)
|
|
defer { try? FileManager.default.removeItem(at: base) }
|
|
let store = BLEIncomingFileStore(baseDirectory: base)
|
|
let incoming = try store.incomingDirectory(subdirectory: "voicenotes/incoming")
|
|
|
|
// The in-flight partial is the LRU-oldest eviction candidate; without
|
|
// the voice_live_ pattern guard it would be deleted first, unlinking
|
|
// the inode under the coordinator's open FileHandle.
|
|
let inFlight = incoming.appendingPathComponent("voice_live_00112233445566ff_1122334455667788_dm.aac")
|
|
let evictable = incoming.appendingPathComponent("voice_old.m4a")
|
|
try Data(count: 51 * 1024 * 1024).write(to: inFlight)
|
|
try Data(count: 51 * 1024 * 1024).write(to: evictable)
|
|
try FileManager.default.setAttributes([.modificationDate: Date(timeIntervalSinceNow: -7200)], ofItemAtPath: inFlight.path)
|
|
try FileManager.default.setAttributes([.modificationDate: Date(timeIntervalSinceNow: -60)], ofItemAtPath: evictable.path)
|
|
|
|
// 102 MB used against the 100 MB quota forces one eviction. This is
|
|
// the finalized-file arrival path (BLEFileTransferHandler via
|
|
// BLEService), which knows nothing about in-flight captures — the
|
|
// store itself must protect them.
|
|
store.enforceQuota(reservingBytes: 0)
|
|
|
|
#expect(FileManager.default.fileExists(atPath: inFlight.path))
|
|
#expect(!FileManager.default.fileExists(atPath: evictable.path))
|
|
}
|
|
|
|
@Test
|
|
func panicWipeDeletesEveryManagedMediaFileAndRecreatesEmptyDirectories() throws {
|
|
let base = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("panic-media-wipe-\(UUID().uuidString)", isDirectory: true)
|
|
defer { try? FileManager.default.removeItem(at: base) }
|
|
let store = BLEIncomingFileStore(baseDirectory: base)
|
|
let subdirectories = [
|
|
"voicenotes/incoming",
|
|
"voicenotes/outgoing",
|
|
"images/incoming",
|
|
"images/outgoing",
|
|
"files/incoming",
|
|
"files/outgoing"
|
|
]
|
|
|
|
for subdirectory in subdirectories {
|
|
let directory = base
|
|
.appendingPathComponent("files", isDirectory: true)
|
|
.appendingPathComponent(subdirectory, isDirectory: true)
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
try Data("secret".utf8).write(to: directory.appendingPathComponent("artifact.bin"))
|
|
}
|
|
let unmanaged = base.appendingPathComponent("files/legacy/secret.bin")
|
|
try FileManager.default.createDirectory(at: unmanaged.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
try Data("legacy".utf8).write(to: unmanaged)
|
|
|
|
try store.panicWipe()
|
|
|
|
#expect(!FileManager.default.fileExists(atPath: unmanaged.path))
|
|
for subdirectory in subdirectories {
|
|
let directory = base
|
|
.appendingPathComponent("files", isDirectory: true)
|
|
.appendingPathComponent(subdirectory, isDirectory: true)
|
|
var isDirectory: ObjCBool = false
|
|
#expect(FileManager.default.fileExists(atPath: directory.path, isDirectory: &isDirectory))
|
|
#expect(isDirectory.boolValue)
|
|
#expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty)
|
|
}
|
|
}
|
|
|
|
@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)
|
|
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)
|
|
#expect(
|
|
store.privateMediaReceiptState(messageID: messageID)
|
|
== .tombstoned
|
|
)
|
|
|
|
try store.panicWipe()
|
|
|
|
#expect(
|
|
store.privateMediaReceiptState(messageID: messageID)
|
|
== .absent
|
|
)
|
|
}
|
|
|
|
@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
|
|
func panicWipeAttemptsDeletionWhenMarkerPersistenceFails() throws {
|
|
enum MarkerFailure: Error { case unavailable }
|
|
|
|
let base = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(
|
|
"panic-marker-failure-\(UUID().uuidString)",
|
|
isDirectory: true
|
|
)
|
|
defer { try? FileManager.default.removeItem(at: base) }
|
|
let secret = base
|
|
.appendingPathComponent("files/images/outgoing", isDirectory: true)
|
|
.appendingPathComponent("secret.jpg")
|
|
try FileManager.default.createDirectory(
|
|
at: secret.deletingLastPathComponent(),
|
|
withIntermediateDirectories: true
|
|
)
|
|
try Data("secret".utf8).write(to: secret)
|
|
let store = BLEIncomingFileStore(
|
|
baseDirectory: base,
|
|
panicMarkerWriter: { _, _ in throw MarkerFailure.unavailable }
|
|
)
|
|
|
|
do {
|
|
try store.panicWipe(hasDurablePendingMarker: false)
|
|
Issue.record("Expected the missing durable marker to fail closed")
|
|
} catch {
|
|
// The marker error is reported only after the deletion attempt.
|
|
}
|
|
|
|
#expect(!FileManager.default.fileExists(atPath: secret.path))
|
|
#expect(
|
|
FileManager.default.fileExists(
|
|
atPath: secret.deletingLastPathComponent().path
|
|
)
|
|
)
|
|
}
|
|
|
|
@Test
|
|
func externalMarkerAllowsDeletionToCommitWhenFileMarkerFails() throws {
|
|
enum MarkerFailure: Error { case unavailable }
|
|
|
|
let base = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(
|
|
"panic-external-marker-\(UUID().uuidString)",
|
|
isDirectory: true
|
|
)
|
|
defer { try? FileManager.default.removeItem(at: base) }
|
|
let secret = base
|
|
.appendingPathComponent("files/voicenotes/incoming", isDirectory: true)
|
|
.appendingPathComponent("secret.m4a")
|
|
try FileManager.default.createDirectory(
|
|
at: secret.deletingLastPathComponent(),
|
|
withIntermediateDirectories: true
|
|
)
|
|
try Data("secret".utf8).write(to: secret)
|
|
let store = BLEIncomingFileStore(
|
|
baseDirectory: base,
|
|
panicMarkerWriter: { _, _ in throw MarkerFailure.unavailable }
|
|
)
|
|
|
|
try store.panicWipe(hasDurablePendingMarker: true)
|
|
|
|
#expect(!FileManager.default.fileExists(atPath: secret.path))
|
|
}
|
|
|
|
@Test
|
|
func panicRecoveryMarkerPersistsUntilExplicitCommit() throws {
|
|
let base = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(
|
|
"panic-recovery-marker-\(UUID().uuidString)",
|
|
isDirectory: true
|
|
)
|
|
defer { try? FileManager.default.removeItem(at: base) }
|
|
let store = BLEIncomingFileStore(baseDirectory: base)
|
|
|
|
try store.markPanicRecoveryPending()
|
|
#expect(try store.isPanicRecoveryPending())
|
|
try store.panicWipe(hasDurablePendingMarker: true)
|
|
#expect(try store.isPanicRecoveryPending())
|
|
|
|
try store.completePanicRecovery()
|
|
|
|
#expect(try !store.isPanicRecoveryPending())
|
|
}
|
|
|
|
private func expectNoSideEffects(_ recorder: Recorder) {
|
|
#expect(recorder.signedNameQueries.isEmpty)
|
|
#expect(recorder.trackedPackets.isEmpty)
|
|
#expect(recorder.quotaReservations.isEmpty)
|
|
#expect(recorder.saveCalls.isEmpty)
|
|
#expect(recorder.lastSeenUpdates.isEmpty)
|
|
#expect(recorder.deliveredMessages.isEmpty)
|
|
}
|
|
|
|
private func makePeerInfo(
|
|
_ peerID: PeerID,
|
|
nickname: String,
|
|
isVerified: Bool,
|
|
isConnected: Bool = true,
|
|
signingPublicKey: Data? = nil
|
|
) -> BLEPeerInfo {
|
|
BLEPeerInfo(
|
|
peerID: peerID,
|
|
nickname: nickname,
|
|
isConnected: isConnected,
|
|
noisePublicKey: nil,
|
|
signingPublicKey: signingPublicKey,
|
|
isVerifiedNickname: isVerified,
|
|
lastSeen: Date(timeIntervalSince1970: 999)
|
|
)
|
|
}
|
|
|
|
private func makeFileTransferPacket(
|
|
sender: PeerID,
|
|
mimeType: String?,
|
|
content: Data,
|
|
ttl: UInt8 = TransportConfig.messageTTLDefault,
|
|
recipientID: Data? = nil,
|
|
fileName: String = "sample",
|
|
hasSignature: Bool = true
|
|
) throws -> BitchatPacket {
|
|
let filePacket = BitchatFilePacket(
|
|
fileName: fileName,
|
|
fileSize: UInt64(content.count),
|
|
mimeType: mimeType,
|
|
content: content
|
|
)
|
|
let payload = try #require(filePacket.encode())
|
|
return BitchatPacket(
|
|
type: MessageType.fileTransfer.rawValue,
|
|
senderID: Data(hexString: sender.id) ?? Data(),
|
|
recipientID: recipientID,
|
|
timestamp: 900_000,
|
|
payload: payload,
|
|
signature: hasSignature ? Data(repeating: 0x5A, count: 64) : nil,
|
|
ttl: ttl
|
|
)
|
|
}
|
|
}
|