mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
[codex] Refine BLE ingress fanout (#1280)
* Refactor BLE transport event handling * Make image output paths unique * Keep queued Nostr read receipts alive * Refine BLE ingress fanout * Rediscover BLE service after invalidation * Extract BLE notification retry buffer * Extract BLE inbound write buffer * Extract BLE fragment assembly buffer * Tidy secure log handling from device run * Allow self-authored RSR ingress replies * Harden read receipt queue test timing --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
@@ -202,8 +202,12 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
let decryptedData = try AES.GCM.open(sealedBox, using: encryptionKey)
|
||||
cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData)
|
||||
} catch {
|
||||
// Log error but continue with empty cache
|
||||
SecureLogger.error(error, context: "Failed to load identity cache", category: .security)
|
||||
cache = IdentityCache()
|
||||
let deleted = keychain.deleteIdentityKey(forKey: cacheKey)
|
||||
SecureLogger.warning(
|
||||
"Discarded unreadable identity cache; starting fresh (deleted=\(deleted), error=\(error.localizedDescription))",
|
||||
category: .security
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import BitFoundation
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
struct BLEFanoutSelection: Equatable {
|
||||
let peripheralIDs: Set<String>
|
||||
let centralIDs: Set<String>
|
||||
}
|
||||
|
||||
enum BLEFanoutSelector {
|
||||
static func selectLinks(
|
||||
peripheralIDs: [String],
|
||||
centralIDs: [String],
|
||||
ingressLink: BLEIngressLinkID?,
|
||||
excludedLinks: Set<BLEIngressLinkID> = [],
|
||||
directedPeerHint: PeerID?,
|
||||
packetType: UInt8,
|
||||
messageID: String
|
||||
) -> BLEFanoutSelection {
|
||||
let allowed = allowedLinks(
|
||||
peripheralIDs: peripheralIDs,
|
||||
centralIDs: centralIDs,
|
||||
ingressLink: ingressLink,
|
||||
excludedLinks: excludedLinks
|
||||
)
|
||||
|
||||
guard shouldSubset(packetType: packetType, directedPeerHint: directedPeerHint) else {
|
||||
return BLEFanoutSelection(
|
||||
peripheralIDs: Set(allowed.peripheralIDs),
|
||||
centralIDs: Set(allowed.centralIDs)
|
||||
)
|
||||
}
|
||||
|
||||
return BLEFanoutSelection(
|
||||
peripheralIDs: deterministicSubset(
|
||||
ids: allowed.peripheralIDs,
|
||||
k: subsetSize(for: allowed.peripheralIDs.count),
|
||||
seed: messageID
|
||||
),
|
||||
centralIDs: deterministicSubset(
|
||||
ids: allowed.centralIDs,
|
||||
k: subsetSize(for: allowed.centralIDs.count),
|
||||
seed: messageID
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private static func allowedLinks(
|
||||
peripheralIDs: [String],
|
||||
centralIDs: [String],
|
||||
ingressLink: BLEIngressLinkID?,
|
||||
excludedLinks: Set<BLEIngressLinkID>
|
||||
) -> (peripheralIDs: [String], centralIDs: [String]) {
|
||||
var allowedPeripheralIDs = peripheralIDs
|
||||
var allowedCentralIDs = centralIDs
|
||||
var blockedLinks = excludedLinks
|
||||
|
||||
if let ingressLink {
|
||||
blockedLinks.insert(ingressLink)
|
||||
}
|
||||
|
||||
allowedPeripheralIDs.removeAll { blockedLinks.contains(.peripheral($0)) }
|
||||
allowedCentralIDs.removeAll { blockedLinks.contains(.central($0)) }
|
||||
|
||||
return (allowedPeripheralIDs, allowedCentralIDs)
|
||||
}
|
||||
|
||||
private static func shouldSubset(packetType: UInt8, directedPeerHint: PeerID?) -> Bool {
|
||||
directedPeerHint == nil
|
||||
&& packetType != MessageType.fragment.rawValue
|
||||
&& packetType != MessageType.announce.rawValue
|
||||
&& packetType != MessageType.requestSync.rawValue
|
||||
}
|
||||
|
||||
private static func subsetSize(for count: Int) -> Int {
|
||||
guard count > 0 else { return 0 }
|
||||
if count <= 2 { return count }
|
||||
|
||||
var value = count - 1
|
||||
var bits = 0
|
||||
while value > 0 {
|
||||
value >>= 1
|
||||
bits += 1
|
||||
}
|
||||
return min(count, max(1, bits + 1))
|
||||
}
|
||||
|
||||
private static func deterministicSubset(ids: [String], k: Int, seed: String) -> Set<String> {
|
||||
guard k > 0 && ids.count > k else { return Set(ids) }
|
||||
|
||||
var scored: [(score: [UInt8], id: String)] = []
|
||||
for id in ids {
|
||||
let data = (seed + "::" + id).data(using: .utf8) ?? Data()
|
||||
let digest = Array(SHA256.hash(data: data))
|
||||
scored.append((digest, id))
|
||||
}
|
||||
|
||||
scored.sort { lhs, rhs in
|
||||
for index in 0..<min(lhs.score.count, rhs.score.count) {
|
||||
if lhs.score[index] != rhs.score[index] {
|
||||
return lhs.score[index] < rhs.score[index]
|
||||
}
|
||||
}
|
||||
return lhs.id < rhs.id
|
||||
}
|
||||
|
||||
return Set(scored.prefix(k).map(\.id))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct BLEFragmentKey: Hashable, Equatable {
|
||||
let sender: UInt64
|
||||
let id: UInt64
|
||||
}
|
||||
|
||||
struct BLEFragmentHeader: Equatable {
|
||||
let key: BLEFragmentKey
|
||||
let index: Int
|
||||
let total: Int
|
||||
let originalType: UInt8
|
||||
let fragmentData: Data
|
||||
let isBroadcastFragment: Bool
|
||||
|
||||
var idLogString: String {
|
||||
String(format: "%016llx", key.id)
|
||||
}
|
||||
|
||||
init?(packet: BitchatPacket) {
|
||||
// Minimum header: 8 bytes ID + 2 index + 2 total + 1 type.
|
||||
guard packet.payload.count >= 13 else { return nil }
|
||||
|
||||
var senderU64: UInt64 = 0
|
||||
for byte in packet.senderID.prefix(8) {
|
||||
senderU64 = (senderU64 << 8) | UInt64(byte)
|
||||
}
|
||||
|
||||
var fragmentU64: UInt64 = 0
|
||||
for byte in packet.payload.prefix(8) {
|
||||
fragmentU64 = (fragmentU64 << 8) | UInt64(byte)
|
||||
}
|
||||
|
||||
let index = Int((UInt16(packet.payload[8]) << 8) | UInt16(packet.payload[9]))
|
||||
let total = Int((UInt16(packet.payload[10]) << 8) | UInt16(packet.payload[11]))
|
||||
|
||||
guard total > 0 && total <= 10_000 && index >= 0 && index < total else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let isBroadcastFragment: Bool = {
|
||||
guard let recipient = packet.recipientID else { return true }
|
||||
return recipient.count == 8 && recipient.allSatisfy { $0 == 0xFF }
|
||||
}()
|
||||
|
||||
self.key = BLEFragmentKey(sender: senderU64, id: fragmentU64)
|
||||
self.index = index
|
||||
self.total = total
|
||||
self.originalType = packet.payload[12]
|
||||
self.fragmentData = Data(packet.payload.suffix(from: 13))
|
||||
self.isBroadcastFragment = isBroadcastFragment
|
||||
}
|
||||
}
|
||||
|
||||
struct BLEFragmentAssemblyBuffer {
|
||||
enum AppendResult: Equatable {
|
||||
case stored(header: BLEFragmentHeader, started: Bool)
|
||||
case complete(header: BLEFragmentHeader, reassembledData: Data, started: Bool)
|
||||
case oversized(header: BLEFragmentHeader, projectedSize: Int, limit: Int, started: Bool)
|
||||
}
|
||||
|
||||
private struct Metadata {
|
||||
let type: UInt8
|
||||
let total: Int
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
private var fragmentsByKey: [BLEFragmentKey: [Int: Data]] = [:]
|
||||
private var metadataByKey: [BLEFragmentKey: Metadata] = [:]
|
||||
|
||||
mutating func removeAll() {
|
||||
fragmentsByKey.removeAll()
|
||||
metadataByKey.removeAll()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
mutating func removeExpired(before cutoff: Date) -> Int {
|
||||
let expiredKeys = metadataByKey
|
||||
.filter { $0.value.timestamp < cutoff }
|
||||
.map(\.key)
|
||||
|
||||
for key in expiredKeys {
|
||||
fragmentsByKey.removeValue(forKey: key)
|
||||
metadataByKey.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
return expiredKeys.count
|
||||
}
|
||||
|
||||
mutating func append(
|
||||
_ header: BLEFragmentHeader,
|
||||
maxInFlightAssemblies: Int,
|
||||
now: Date = Date()
|
||||
) -> AppendResult {
|
||||
let started = startAssemblyIfNeeded(for: header, maxInFlightAssemblies: maxInFlightAssemblies, now: now)
|
||||
|
||||
let currentSize = fragmentsByKey[header.key]?.values.reduce(0) { $0 + $1.count } ?? 0
|
||||
let limit = Self.assemblyLimit(for: header.originalType)
|
||||
let projectedSize = currentSize + header.fragmentData.count
|
||||
|
||||
guard projectedSize <= limit else {
|
||||
fragmentsByKey.removeValue(forKey: header.key)
|
||||
metadataByKey.removeValue(forKey: header.key)
|
||||
return .oversized(header: header, projectedSize: projectedSize, limit: limit, started: started)
|
||||
}
|
||||
|
||||
fragmentsByKey[header.key]?[header.index] = header.fragmentData
|
||||
|
||||
guard let fragments = fragmentsByKey[header.key],
|
||||
fragments.count == header.total else {
|
||||
return .stored(header: header, started: started)
|
||||
}
|
||||
|
||||
let reassembled = (0..<header.total).reduce(into: Data()) { data, index in
|
||||
if let fragment = fragments[index] {
|
||||
data.append(fragment)
|
||||
}
|
||||
}
|
||||
|
||||
fragmentsByKey.removeValue(forKey: header.key)
|
||||
metadataByKey.removeValue(forKey: header.key)
|
||||
|
||||
return .complete(header: header, reassembledData: reassembled, started: started)
|
||||
}
|
||||
|
||||
private mutating func startAssemblyIfNeeded(
|
||||
for header: BLEFragmentHeader,
|
||||
maxInFlightAssemblies: Int,
|
||||
now: Date
|
||||
) -> Bool {
|
||||
guard fragmentsByKey[header.key] == nil else { return false }
|
||||
|
||||
if fragmentsByKey.count >= maxInFlightAssemblies,
|
||||
let oldest = metadataByKey.min(by: { $0.value.timestamp < $1.value.timestamp })?.key {
|
||||
fragmentsByKey.removeValue(forKey: oldest)
|
||||
metadataByKey.removeValue(forKey: oldest)
|
||||
}
|
||||
|
||||
fragmentsByKey[header.key] = [:]
|
||||
metadataByKey[header.key] = Metadata(type: header.originalType, total: header.total, timestamp: now)
|
||||
return true
|
||||
}
|
||||
|
||||
private static func assemblyLimit(for originalType: UInt8) -> Int {
|
||||
if originalType == MessageType.fileTransfer.rawValue {
|
||||
// Allow headroom for TLV metadata and binary framing overhead.
|
||||
return FileTransferLimits.maxFramedFileBytes
|
||||
}
|
||||
|
||||
return FileTransferLimits.maxPayloadBytes
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct BLEInboundWriteChunk: Equatable {
|
||||
let offset: Int
|
||||
let data: Data
|
||||
}
|
||||
|
||||
struct BLEInboundWriteAppendMetadata: Equatable {
|
||||
let accumulatedBytes: Int
|
||||
let appendedBytes: Int
|
||||
let offsets: [Int]
|
||||
let packetType: UInt8?
|
||||
}
|
||||
|
||||
struct BLEInboundWriteBuffer {
|
||||
enum AppendResult {
|
||||
case decoded(packet: BitchatPacket, metadata: BLEInboundWriteAppendMetadata)
|
||||
case waiting(metadata: BLEInboundWriteAppendMetadata)
|
||||
case oversized(metadata: BLEInboundWriteAppendMetadata)
|
||||
}
|
||||
|
||||
private var buffersByCentralID: [String: Data] = [:]
|
||||
|
||||
mutating func removeAll() {
|
||||
buffersByCentralID.removeAll()
|
||||
}
|
||||
|
||||
mutating func append(
|
||||
chunks: [BLEInboundWriteChunk],
|
||||
for centralID: String,
|
||||
capBytes: Int
|
||||
) -> AppendResult {
|
||||
var combined = buffersByCentralID[centralID] ?? Data()
|
||||
var appendedBytes = 0
|
||||
var offsets: [Int] = []
|
||||
|
||||
for chunk in chunks where !chunk.data.isEmpty {
|
||||
offsets.append(chunk.offset)
|
||||
let end = chunk.offset + chunk.data.count
|
||||
|
||||
if combined.count < end {
|
||||
combined.append(Data(repeating: 0, count: end - combined.count))
|
||||
}
|
||||
|
||||
combined.replaceSubrange(chunk.offset..<end, with: chunk.data)
|
||||
appendedBytes += chunk.data.count
|
||||
}
|
||||
|
||||
let metadata = BLEInboundWriteAppendMetadata(
|
||||
accumulatedBytes: combined.count,
|
||||
appendedBytes: appendedBytes,
|
||||
offsets: offsets,
|
||||
packetType: combined.count >= 2 ? combined[1] : nil
|
||||
)
|
||||
|
||||
if let packet = BinaryProtocol.decode(combined) {
|
||||
buffersByCentralID.removeValue(forKey: centralID)
|
||||
return .decoded(packet: packet, metadata: metadata)
|
||||
}
|
||||
|
||||
guard combined.count <= capBytes else {
|
||||
buffersByCentralID.removeValue(forKey: centralID)
|
||||
return .oversized(metadata: metadata)
|
||||
}
|
||||
|
||||
buffersByCentralID[centralID] = combined
|
||||
return .waiting(metadata: metadata)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
enum BLEIngressLinkID: Hashable, Equatable {
|
||||
case peripheral(String)
|
||||
case central(String)
|
||||
}
|
||||
|
||||
struct BLEIngressPacketContext: Equatable {
|
||||
let receivedFromPeerID: PeerID
|
||||
let validationPeerID: PeerID
|
||||
}
|
||||
|
||||
struct BLEIngressLinkRecord: Equatable {
|
||||
let link: BLEIngressLinkID
|
||||
let peerID: PeerID
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
enum BLEIngressRejection: Error, Equatable {
|
||||
case selfLoopback(packetType: UInt8)
|
||||
case directSenderMismatch(boundPeerID: PeerID, claimedSenderID: PeerID)
|
||||
}
|
||||
|
||||
struct BLEIngressLinkRegistry {
|
||||
private var ingressByMessageID: [String: BLEIngressLinkRecord] = [:]
|
||||
|
||||
var isEmpty: Bool {
|
||||
ingressByMessageID.isEmpty
|
||||
}
|
||||
|
||||
mutating func removeAll() {
|
||||
ingressByMessageID.removeAll()
|
||||
}
|
||||
|
||||
func record(for packet: BitchatPacket) -> BLEIngressLinkRecord? {
|
||||
ingressByMessageID[Self.messageID(for: packet)]
|
||||
}
|
||||
|
||||
func link(for packet: BitchatPacket) -> BLEIngressLinkID? {
|
||||
record(for: packet)?.link
|
||||
}
|
||||
|
||||
func peerID(for packet: BitchatPacket) -> PeerID? {
|
||||
record(for: packet)?.peerID
|
||||
}
|
||||
|
||||
mutating func recordIfNew(
|
||||
_ packet: BitchatPacket,
|
||||
link: BLEIngressLinkID,
|
||||
peerID: PeerID,
|
||||
now: Date = Date(),
|
||||
lifetime: TimeInterval
|
||||
) -> Bool {
|
||||
let messageID = Self.messageID(for: packet)
|
||||
if let existing = ingressByMessageID[messageID],
|
||||
now.timeIntervalSince(existing.timestamp) <= lifetime {
|
||||
return false
|
||||
}
|
||||
|
||||
ingressByMessageID[messageID] = BLEIngressLinkRecord(link: link, peerID: peerID, timestamp: now)
|
||||
return true
|
||||
}
|
||||
|
||||
mutating func prune(before cutoff: Date) {
|
||||
ingressByMessageID = ingressByMessageID.filter { $0.value.timestamp >= cutoff }
|
||||
}
|
||||
|
||||
static func packetContext(
|
||||
for packet: BitchatPacket,
|
||||
claimedSenderID: PeerID,
|
||||
boundPeerID: PeerID?,
|
||||
localPeerID: PeerID,
|
||||
directAnnounceTTL: UInt8
|
||||
) -> Result<BLEIngressPacketContext, BLEIngressRejection> {
|
||||
if claimedSenderID == localPeerID,
|
||||
!isSelfAuthoredSyncResponse(packet) {
|
||||
return .failure(.selfLoopback(packetType: packet.type))
|
||||
}
|
||||
|
||||
if let boundPeerID,
|
||||
boundPeerID != claimedSenderID,
|
||||
requiresDirectSenderBinding(packet, directAnnounceTTL: directAnnounceTTL) {
|
||||
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
|
||||
}
|
||||
|
||||
let receivedFromPeerID = boundPeerID ?? claimedSenderID
|
||||
let validationPeerID = packet.isRSR ? receivedFromPeerID : claimedSenderID
|
||||
return .success(BLEIngressPacketContext(
|
||||
receivedFromPeerID: receivedFromPeerID,
|
||||
validationPeerID: validationPeerID
|
||||
))
|
||||
}
|
||||
|
||||
static func messageID(for packet: BitchatPacket) -> String {
|
||||
let senderID = packet.senderID.hexEncodedString()
|
||||
let digestPrefix = packet.payload.sha256Hash().prefix(4).hexEncodedString()
|
||||
return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
|
||||
}
|
||||
|
||||
private static func requiresDirectSenderBinding(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool {
|
||||
packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
|
||||
}
|
||||
|
||||
private static func isSelfAuthoredSyncResponse(_ packet: BitchatPacket) -> Bool {
|
||||
packet.isRSR && packet.ttl == 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
|
||||
struct BLEPendingNotification<Target> {
|
||||
let data: Data
|
||||
let targets: [Target]?
|
||||
}
|
||||
|
||||
struct BLEOutboundNotificationBuffer<Target> {
|
||||
enum EnqueueResult {
|
||||
case enqueued(count: Int)
|
||||
case full(count: Int)
|
||||
}
|
||||
|
||||
private var notifications: [BLEPendingNotification<Target>] = []
|
||||
|
||||
var count: Int {
|
||||
notifications.count
|
||||
}
|
||||
|
||||
var isEmpty: Bool {
|
||||
notifications.isEmpty
|
||||
}
|
||||
|
||||
mutating func removeAll() {
|
||||
notifications.removeAll()
|
||||
}
|
||||
|
||||
mutating func enqueue(data: Data, targets: [Target]?, capCount: Int) -> EnqueueResult {
|
||||
guard notifications.count < capCount else {
|
||||
return .full(count: notifications.count)
|
||||
}
|
||||
|
||||
notifications.append(BLEPendingNotification(data: data, targets: targets))
|
||||
return .enqueued(count: notifications.count)
|
||||
}
|
||||
|
||||
mutating func takeAll() -> [BLEPendingNotification<Target>] {
|
||||
let pending = notifications
|
||||
notifications.removeAll()
|
||||
return pending
|
||||
}
|
||||
|
||||
mutating func prepend(_ pending: [BLEPendingNotification<Target>]) {
|
||||
guard !pending.isEmpty else { return }
|
||||
notifications.insert(contentsOf: pending, at: 0)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import BitFoundation
|
||||
import Foundation
|
||||
import CoreBluetooth
|
||||
import Combine
|
||||
import CryptoKit
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#endif
|
||||
@@ -88,9 +87,7 @@ final class BLEService: NSObject {
|
||||
private let meshTopology = MeshTopologyTracker()
|
||||
|
||||
// 5. Fragment Reassembly (necessary for messages > MTU)
|
||||
private struct FragmentKey: Hashable { let sender: UInt64; let id: UInt64 }
|
||||
private var incomingFragments: [FragmentKey: [Int: Data]] = [:]
|
||||
private var fragmentMetadata: [FragmentKey: (type: UInt8, total: Int, timestamp: Date)] = [:]
|
||||
private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer()
|
||||
private struct ActiveTransferState {
|
||||
let totalFragments: Int
|
||||
var sentFragments: Int
|
||||
@@ -139,21 +136,17 @@ final class BLEService: NSObject {
|
||||
// Noise typed payloads (ACKs, read receipts, etc.) pending handshake
|
||||
private var pendingNoisePayloadsAfterHandshake: [PeerID: [Data]] = [:]
|
||||
// Queue for notifications that failed due to full queue
|
||||
private var pendingNotifications: [(data: Data, centrals: [CBCentral]?)] = []
|
||||
private var pendingNotifications = BLEOutboundNotificationBuffer<CBCentral>()
|
||||
|
||||
// Accumulate long write chunks per central until a full frame decodes
|
||||
private var pendingWriteBuffers: [String: Data] = [:]
|
||||
private var pendingWriteBuffers = BLEInboundWriteBuffer()
|
||||
// Relay jitter scheduling to reduce redundant floods
|
||||
private var scheduledRelays: [String: DispatchWorkItem] = [:]
|
||||
// Track short-lived traffic bursts to adapt announces/scanning under load
|
||||
private var recentPacketTimestamps: [Date] = []
|
||||
|
||||
// Ingress link tracking for last-hop suppression
|
||||
private enum LinkID: Hashable {
|
||||
case peripheral(String)
|
||||
case central(String)
|
||||
}
|
||||
private var ingressByMessageID: [String: (link: LinkID, timestamp: Date)] = [:]
|
||||
// Ingress link tracking for duplicate and last-hop suppression
|
||||
private var ingressLinks = BLEIngressLinkRegistry()
|
||||
|
||||
private struct PendingFragmentTransfer {
|
||||
let packet: BitchatPacket
|
||||
@@ -359,8 +352,9 @@ final class BLEService: NSObject {
|
||||
pendingPeripheralWrites.removeAll()
|
||||
pendingFragmentTransfers.removeAll()
|
||||
pendingNotifications.removeAll()
|
||||
fragmentAssemblyBuffer.removeAll()
|
||||
pendingDirectedRelays.removeAll()
|
||||
ingressByMessageID.removeAll()
|
||||
ingressLinks.removeAll()
|
||||
recentPacketTimestamps.removeAll()
|
||||
scheduledRelays.values.forEach { $0.cancel() }
|
||||
scheduledRelays.removeAll()
|
||||
@@ -576,8 +570,7 @@ final class BLEService: NSObject {
|
||||
let cancelledTransfers: [(id: String, items: [DispatchWorkItem])] = collectionsQueue.sync(flags: .barrier) {
|
||||
let entries = activeTransfers.map { ($0.key, $0.value.workItems) }
|
||||
peers.removeAll()
|
||||
incomingFragments.removeAll()
|
||||
fragmentMetadata.removeAll()
|
||||
fragmentAssemblyBuffer.removeAll()
|
||||
activeTransfers.removeAll()
|
||||
// Also clear pending message queues to avoid stale state across sessions
|
||||
pendingMessagesAfterHandshake.removeAll()
|
||||
@@ -819,42 +812,28 @@ final class BLEService: NSObject {
|
||||
case unknown
|
||||
}
|
||||
|
||||
private struct IngressPacketContext {
|
||||
let receivedFromPeerID: PeerID
|
||||
let validationPeerID: PeerID
|
||||
}
|
||||
|
||||
private func requiresDirectSenderBinding(_ packet: BitchatPacket) -> Bool {
|
||||
packet.type == MessageType.announce.rawValue && packet.ttl == messageTTL
|
||||
}
|
||||
|
||||
private func isSelfAuthoredSyncResponse(_ packet: BitchatPacket) -> Bool {
|
||||
packet.isRSR && packet.ttl == 0
|
||||
}
|
||||
|
||||
private func makeIngressPacketContext(
|
||||
for packet: BitchatPacket,
|
||||
claimedSenderID: PeerID,
|
||||
boundPeerID: PeerID?,
|
||||
linkDescription: String
|
||||
) -> IngressPacketContext? {
|
||||
if claimedSenderID == myPeerID,
|
||||
!isSelfAuthoredSyncResponse(packet) {
|
||||
) -> BLEIngressPacketContext? {
|
||||
switch BLEIngressLinkRegistry.packetContext(
|
||||
for: packet,
|
||||
claimedSenderID: claimedSenderID,
|
||||
boundPeerID: boundPeerID,
|
||||
localPeerID: myPeerID,
|
||||
directAnnounceTTL: messageTTL
|
||||
) {
|
||||
case .success(let context):
|
||||
return context
|
||||
case .failure(.selfLoopback):
|
||||
SecureLogger.debug("↩️ Dropping BLE self-loopback packet type \(packet.type) from \(linkDescription)", category: .session)
|
||||
return nil
|
||||
}
|
||||
|
||||
if let boundPeerID, boundPeerID != claimedSenderID, requiresDirectSenderBinding(packet) {
|
||||
case .failure(.directSenderMismatch(let boundPeerID, let claimedSenderID)):
|
||||
SecureLogger.warning("🚫 SECURITY: Sender ID spoofing attempt detected! \(linkDescription) claimed to be \(claimedSenderID.id.prefix(8))… but is bound to \(boundPeerID.id.prefix(8))…", category: .security)
|
||||
return nil
|
||||
}
|
||||
|
||||
let receivedFromPeerID = boundPeerID ?? claimedSenderID
|
||||
let validationPeerID = packet.isRSR ? receivedFromPeerID : claimedSenderID
|
||||
return IngressPacketContext(
|
||||
receivedFromPeerID: receivedFromPeerID,
|
||||
validationPeerID: validationPeerID
|
||||
)
|
||||
}
|
||||
|
||||
private func validatePacket(_ packet: BitchatPacket, from peerID: PeerID, connectionSource: ConnectionSource = .unknown) -> Bool {
|
||||
@@ -887,18 +866,14 @@ final class BLEService: NSObject {
|
||||
return true
|
||||
}
|
||||
|
||||
private func recordIngressIfNew(_ packet: BitchatPacket, link: LinkID) -> Bool {
|
||||
let messageID = makeMessageID(for: packet)
|
||||
let now = Date()
|
||||
|
||||
private func recordIngressIfNew(_ packet: BitchatPacket, link: BLEIngressLinkID, peerID: PeerID) -> Bool {
|
||||
return collectionsQueue.sync(flags: .barrier) {
|
||||
if let existing = ingressByMessageID[messageID],
|
||||
now.timeIntervalSince(existing.timestamp) <= TransportConfig.bleIngressRecordLifetimeSeconds {
|
||||
return false
|
||||
}
|
||||
|
||||
ingressByMessageID[messageID] = (link, now)
|
||||
return true
|
||||
ingressLinks.recordIfNew(
|
||||
packet,
|
||||
link: link,
|
||||
peerID: peerID,
|
||||
lifetime: TransportConfig.bleIngressRecordLifetimeSeconds
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1005,9 +980,14 @@ final class BLEService: NSObject {
|
||||
private func enqueuePendingNotification(data: Data, centrals: [CBCentral]?, context: String, attempt: Int = 0) {
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if self.pendingNotifications.count < TransportConfig.blePendingNotificationsCapCount {
|
||||
self.pendingNotifications.append((data: data, centrals: centrals))
|
||||
SecureLogger.debug("📋 Queued \(context) packet for retry (pending=\(self.pendingNotifications.count))", category: .session)
|
||||
let result = self.pendingNotifications.enqueue(
|
||||
data: data,
|
||||
targets: centrals,
|
||||
capCount: TransportConfig.blePendingNotificationsCapCount
|
||||
)
|
||||
|
||||
if case let .enqueued(count) = result {
|
||||
SecureLogger.debug("📋 Queued \(context) packet for retry (pending=\(count))", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1025,9 +1005,10 @@ final class BLEService: NSObject {
|
||||
}
|
||||
|
||||
private func sendOnAllLinks(packet: BitchatPacket, data: Data, pad: Bool, directedOnlyPeer: PeerID?) {
|
||||
// Determine last-hop link for this message to avoid echoing back
|
||||
// Determine the last-hop peer/link for this message to avoid echoing back over either BLE role.
|
||||
let messageID = makeMessageID(for: packet)
|
||||
let ingressLink: LinkID? = collectionsQueue.sync { ingressByMessageID[messageID]?.link }
|
||||
let ingressRecord = collectionsQueue.sync { ingressLinks.record(for: packet) }
|
||||
let excludedPeerLinks = links(to: ingressRecord?.peerID)
|
||||
let directedPeerHint: PeerID? = {
|
||||
if let explicit = directedOnlyPeer { return explicit }
|
||||
if let recipient = PeerID(str: packet.recipientID?.hexEncodedString()), !recipient.isEmpty {
|
||||
@@ -1073,35 +1054,19 @@ final class BLEService: NSObject {
|
||||
subscribedCentrals = []
|
||||
}
|
||||
|
||||
// Exclude ingress link
|
||||
var allowedPeripheralIDs = connectedPeripheralIDs
|
||||
var allowedCentralIDs = centralIDs
|
||||
if let ingress = ingressLink {
|
||||
switch ingress {
|
||||
case .peripheral(let id):
|
||||
allowedPeripheralIDs.removeAll { $0 == id }
|
||||
case .central(let id):
|
||||
allowedCentralIDs.removeAll { $0 == id }
|
||||
}
|
||||
}
|
||||
|
||||
// For broadcast (no directed peer) and non-fragment, choose a subset deterministically
|
||||
// Special-case control/presence messages: do NOT subset to maximize immediate coverage
|
||||
var selectedPeripheralIDs = Set(allowedPeripheralIDs)
|
||||
var selectedCentralIDs = Set(allowedCentralIDs)
|
||||
if directedPeerHint == nil
|
||||
&& packet.type != MessageType.fragment.rawValue
|
||||
&& packet.type != MessageType.announce.rawValue
|
||||
&& packet.type != MessageType.requestSync.rawValue {
|
||||
let kp = subsetSizeForFanout(allowedPeripheralIDs.count)
|
||||
let kc = subsetSizeForFanout(allowedCentralIDs.count)
|
||||
selectedPeripheralIDs = selectDeterministicSubset(ids: allowedPeripheralIDs, k: kp, seed: messageID)
|
||||
selectedCentralIDs = selectDeterministicSubset(ids: allowedCentralIDs, k: kc, seed: messageID)
|
||||
}
|
||||
let selectedLinks = BLEFanoutSelector.selectLinks(
|
||||
peripheralIDs: connectedPeripheralIDs,
|
||||
centralIDs: centralIDs,
|
||||
ingressLink: ingressRecord?.link,
|
||||
excludedLinks: excludedPeerLinks,
|
||||
directedPeerHint: directedPeerHint,
|
||||
packetType: packet.type,
|
||||
messageID: messageID
|
||||
)
|
||||
|
||||
// If directed and we currently have no links to forward on, spool for a short window
|
||||
if let only = directedPeerHint,
|
||||
selectedPeripheralIDs.isEmpty && selectedCentralIDs.isEmpty,
|
||||
selectedLinks.peripheralIDs.isEmpty && selectedLinks.centralIDs.isEmpty,
|
||||
(packet.type == MessageType.noiseEncrypted.rawValue || packet.type == MessageType.noiseHandshake.rawValue) {
|
||||
spoolDirectedPacket(packet, recipientPeerID: only)
|
||||
}
|
||||
@@ -1109,14 +1074,14 @@ final class BLEService: NSObject {
|
||||
// Writes to selected connected peripherals
|
||||
for s in states where s.isConnected {
|
||||
let pid = s.peripheral.identifier.uuidString
|
||||
guard selectedPeripheralIDs.contains(pid) else { continue }
|
||||
guard selectedLinks.peripheralIDs.contains(pid) else { continue }
|
||||
if let ch = s.characteristic {
|
||||
writeOrEnqueue(data, to: s.peripheral, characteristic: ch, priority: outboundPriority)
|
||||
}
|
||||
}
|
||||
// Notify selected subscribed centrals
|
||||
if let ch = characteristic {
|
||||
let targets = subscribedCentrals.filter { selectedCentralIDs.contains($0.identifier.uuidString) }
|
||||
let targets = subscribedCentrals.filter { selectedLinks.centralIDs.contains($0.identifier.uuidString) }
|
||||
if !targets.isEmpty {
|
||||
let success = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets) ?? false
|
||||
if !success {
|
||||
@@ -2055,6 +2020,15 @@ extension BLEService {
|
||||
}
|
||||
}
|
||||
|
||||
private extension BLEService {
|
||||
static func shouldRediscoverBitChatService(
|
||||
invalidatedServiceUUIDs: [CBUUID],
|
||||
cachedServiceUUIDs: [CBUUID]?
|
||||
) -> Bool {
|
||||
invalidatedServiceUUIDs.contains(serviceUUID) || cachedServiceUUIDs?.contains(serviceUUID) != true
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
// Test-only helper to inject packets into the receive pipeline
|
||||
extension BLEService {
|
||||
@@ -2096,7 +2070,17 @@ extension BLEService {
|
||||
}
|
||||
|
||||
func _test_recordIngressIfNew(packet: BitchatPacket, linkID: String) -> Bool {
|
||||
recordIngressIfNew(packet, link: .central(linkID))
|
||||
recordIngressIfNew(packet, link: .central(linkID), peerID: PeerID(hexData: packet.senderID))
|
||||
}
|
||||
|
||||
static func _test_shouldRediscoverBitChatService(
|
||||
invalidatedServiceUUIDs: [CBUUID],
|
||||
cachedServiceUUIDs: [CBUUID]?
|
||||
) -> Bool {
|
||||
shouldRediscoverBitChatService(
|
||||
invalidatedServiceUUIDs: invalidatedServiceUUIDs,
|
||||
cachedServiceUUIDs: cachedServiceUUIDs
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -2257,7 +2241,7 @@ extension BLEService: CBPeripheralDelegate {
|
||||
peripherals[peripheralUUID] = state
|
||||
}
|
||||
|
||||
if !recordIngressIfNew(packet, link: .peripheral(peripheralUUID)) {
|
||||
if !recordIngressIfNew(packet, link: .peripheral(peripheralUUID), peerID: context.receivedFromPeerID) {
|
||||
continue
|
||||
}
|
||||
processNotificationPacket(
|
||||
@@ -2310,17 +2294,22 @@ extension BLEService: CBPeripheralDelegate {
|
||||
func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {
|
||||
SecureLogger.warning("⚠️ Services modified for \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session)
|
||||
|
||||
// Check if our service was invalidated (peer app quit)
|
||||
let hasOurService = peripheral.services?.contains { $0.uuid == BLEService.serviceUUID } ?? false
|
||||
let shouldRediscover = BLEService.shouldRediscoverBitChatService(
|
||||
invalidatedServiceUUIDs: invalidatedServices.map(\.uuid),
|
||||
cachedServiceUUIDs: peripheral.services?.map(\.uuid)
|
||||
)
|
||||
|
||||
if !hasOurService {
|
||||
// Service is gone - disconnect
|
||||
SecureLogger.warning("❌ BitChat service removed - disconnecting from \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session)
|
||||
centralManager?.cancelPeripheralConnection(peripheral)
|
||||
} else {
|
||||
// Try to rediscover
|
||||
peripheral.discoverServices([BLEService.serviceUUID])
|
||||
guard shouldRediscover else { return }
|
||||
|
||||
let peripheralID = peripheral.identifier.uuidString
|
||||
if var state = peripherals[peripheralID] {
|
||||
state.characteristic = nil
|
||||
state.assembler = NotificationStreamAssembler()
|
||||
peripherals[peripheralID] = state
|
||||
}
|
||||
|
||||
SecureLogger.debug("🔄 BitChat service changed for \(peripheral.name ?? peripheral.identifier.uuidString), rediscovering", category: .session)
|
||||
peripheral.discoverServices([BLEService.serviceUUID])
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
|
||||
@@ -2572,47 +2561,20 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) {
|
||||
SecureLogger.debug("📤 Peripheral manager ready to send more notifications", category: .session)
|
||||
|
||||
// Retry pending notifications now that queue has space
|
||||
drainPendingNotifications(logPrefix: "✅ Sent")
|
||||
}
|
||||
|
||||
private func drainPendingNotifications(logPrefix: String) {
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self,
|
||||
let characteristic = self.characteristic,
|
||||
!self.pendingNotifications.isEmpty else { return }
|
||||
|
||||
let pending = self.pendingNotifications
|
||||
self.pendingNotifications.removeAll()
|
||||
|
||||
// Try to send pending notifications
|
||||
var sentCount = 0
|
||||
for (index, (data, centrals)) in pending.enumerated() {
|
||||
if let centrals = centrals {
|
||||
// Send to specific centrals
|
||||
let success = self.peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: centrals) ?? false
|
||||
if !success {
|
||||
// Still full, re-queue this and all remaining items
|
||||
let remaining = pending.dropFirst(index)
|
||||
self.pendingNotifications.append(contentsOf: remaining)
|
||||
SecureLogger.debug("⚠️ Notification queue still full after \(sentCount) sent, re-queuing \(remaining.count) items", category: .session)
|
||||
break // Stop trying, wait for next ready callback
|
||||
} else {
|
||||
sentCount += 1
|
||||
}
|
||||
} else {
|
||||
// Broadcast to all
|
||||
let success = self.peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: nil) ?? false
|
||||
if !success {
|
||||
// Still full, re-queue this and all remaining items
|
||||
let remaining = pending.dropFirst(index)
|
||||
self.pendingNotifications.append(contentsOf: remaining)
|
||||
SecureLogger.debug("⚠️ Notification queue still full after \(sentCount) sent, re-queuing \(remaining.count) items", category: .session)
|
||||
break
|
||||
} else {
|
||||
sentCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
let pending = self.pendingNotifications.takeAll()
|
||||
let sentCount = self.sendPendingNotifications(pending, characteristic: characteristic)
|
||||
|
||||
if sentCount > 0 {
|
||||
SecureLogger.debug("✅ Sent \(sentCount) pending notifications from retry queue", category: .session)
|
||||
SecureLogger.debug("\(logPrefix) \(sentCount) pending notifications from retry queue", category: .session)
|
||||
}
|
||||
|
||||
if !self.pendingNotifications.isEmpty {
|
||||
@@ -2621,6 +2583,29 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private func sendPendingNotifications(_ pending: [BLEPendingNotification<CBCentral>], characteristic: CBMutableCharacteristic) -> Int {
|
||||
var sentCount = 0
|
||||
|
||||
for (index, notification) in pending.enumerated() {
|
||||
let success = peripheralManager?.updateValue(
|
||||
notification.data,
|
||||
for: characteristic,
|
||||
onSubscribedCentrals: notification.targets
|
||||
) ?? false
|
||||
|
||||
guard success else {
|
||||
let remaining = Array(pending.dropFirst(index))
|
||||
pendingNotifications.prepend(remaining)
|
||||
SecureLogger.debug("⚠️ Notification queue still full after \(sentCount) sent, re-queuing \(remaining.count) items", category: .session)
|
||||
break
|
||||
}
|
||||
|
||||
sentCount += 1
|
||||
}
|
||||
|
||||
return sentCount
|
||||
}
|
||||
|
||||
func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) {
|
||||
// Suppress logs for single write requests to reduce noise
|
||||
if requests.count > 1 {
|
||||
@@ -2641,37 +2626,52 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
// Sort by offset ascending
|
||||
let sorted = group.sorted { $0.offset < $1.offset }
|
||||
let hasMultiple = sorted.count > 1 || (sorted.first?.offset ?? 0) > 0
|
||||
|
||||
// Always merge into a persistent per-central buffer to handle multi-callback long writes
|
||||
var combined = pendingWriteBuffers[centralUUID] ?? Data()
|
||||
var appendedBytes = 0
|
||||
var offsets: [Int] = []
|
||||
for r in sorted {
|
||||
guard let chunk = r.value, !chunk.isEmpty else { continue }
|
||||
offsets.append(r.offset)
|
||||
let end = r.offset + chunk.count
|
||||
if combined.count < end {
|
||||
combined.append(Data(repeating: 0, count: end - combined.count))
|
||||
let chunks = sorted.compactMap { request -> BLEInboundWriteChunk? in
|
||||
guard let data = request.value, !data.isEmpty else { return nil }
|
||||
return BLEInboundWriteChunk(offset: request.offset, data: data)
|
||||
}
|
||||
// Write chunk into the correct position (supports out-of-order and overlapping writes)
|
||||
combined.replaceSubrange(r.offset..<end, with: chunk)
|
||||
appendedBytes += chunk.count
|
||||
}
|
||||
pendingWriteBuffers[centralUUID] = combined
|
||||
|
||||
// Peek type byte for debug: version is at 0, type at 1 when well-formed
|
||||
if combined.count >= 2 {
|
||||
let peekType = combined[1]
|
||||
if peekType != MessageType.announce.rawValue {
|
||||
SecureLogger.debug("📥 Accumulated write from central \(centralUUID): size=\(combined.count) (+\(appendedBytes)) bytes (type=\(peekType)), offsets=\(offsets)", category: .session)
|
||||
let result = pendingWriteBuffers.append(
|
||||
chunks: chunks,
|
||||
for: centralUUID,
|
||||
capBytes: TransportConfig.blePendingWriteBufferCapBytes
|
||||
)
|
||||
|
||||
switch result {
|
||||
case let .decoded(packet, metadata):
|
||||
logAccumulatedCentralWrite(metadata, centralUUID: centralUUID)
|
||||
processDecodedCentralWrite(packet, centralUUID: centralUUID, central: sorted[0].central)
|
||||
|
||||
case let .waiting(metadata):
|
||||
logAccumulatedCentralWrite(metadata, centralUUID: centralUUID)
|
||||
logFailedSingleWriteIfNeeded(hasMultiple: hasMultiple, sortedRequests: sorted)
|
||||
|
||||
case let .oversized(metadata):
|
||||
logAccumulatedCentralWrite(metadata, centralUUID: centralUUID)
|
||||
SecureLogger.warning("⚠️ Dropping oversized pending write buffer (\(metadata.accumulatedBytes) bytes) for central \(centralUUID)", category: .session)
|
||||
logFailedSingleWriteIfNeeded(hasMultiple: hasMultiple, sortedRequests: sorted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try decode the accumulated buffer
|
||||
if let packet = BinaryProtocol.decode(combined) {
|
||||
// Clear buffer on success
|
||||
pendingWriteBuffers.removeValue(forKey: centralUUID)
|
||||
private func logAccumulatedCentralWrite(_ metadata: BLEInboundWriteAppendMetadata, centralUUID: String) {
|
||||
guard let packetType = metadata.packetType,
|
||||
packetType != MessageType.announce.rawValue else { return }
|
||||
|
||||
SecureLogger.debug(
|
||||
"📥 Accumulated write from central \(centralUUID): size=\(metadata.accumulatedBytes) (+\(metadata.appendedBytes)) bytes (type=\(packetType)), offsets=\(metadata.offsets)",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
|
||||
private func logFailedSingleWriteIfNeeded(hasMultiple: Bool, sortedRequests: [CBATTRequest]) {
|
||||
guard !hasMultiple, let raw = sortedRequests.first?.value else { return }
|
||||
|
||||
let prefix = raw.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
|
||||
SecureLogger.error("❌ Failed to decode packet from central (len=\(raw.count), prefix=\(prefix))", category: .session)
|
||||
}
|
||||
|
||||
private func processDecodedCentralWrite(_ packet: BitchatPacket, centralUUID: String, central: CBCentral) {
|
||||
let claimedSenderID = PeerID(hexData: packet.senderID)
|
||||
let context = makeIngressPacketContext(
|
||||
for: packet,
|
||||
@@ -2679,46 +2679,31 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
boundPeerID: centralToPeerID[centralUUID],
|
||||
linkDescription: "Central \(centralUUID.prefix(8))…"
|
||||
)
|
||||
guard let context else { continue }
|
||||
guard let context else { return }
|
||||
|
||||
if !validatePacket(packet, from: context.validationPeerID, connectionSource: .central(centralUUID)) {
|
||||
continue
|
||||
guard validatePacket(packet, from: context.validationPeerID, connectionSource: .central(centralUUID)) else {
|
||||
return
|
||||
}
|
||||
|
||||
if packet.type != MessageType.announce.rawValue {
|
||||
SecureLogger.debug("📦 Decoded (combined) packet type: \(packet.type) from sender: \(claimedSenderID)", category: .session)
|
||||
}
|
||||
if !subscribedCentrals.contains(sorted[0].central) {
|
||||
subscribedCentrals.append(sorted[0].central)
|
||||
|
||||
if !subscribedCentrals.contains(central) {
|
||||
subscribedCentrals.append(central)
|
||||
}
|
||||
if packet.type == MessageType.announce.rawValue {
|
||||
if packet.ttl == messageTTL {
|
||||
|
||||
if packet.type == MessageType.announce.rawValue,
|
||||
packet.ttl == messageTTL {
|
||||
centralToPeerID[centralUUID] = claimedSenderID
|
||||
refreshLocalTopology()
|
||||
}
|
||||
if !recordIngressIfNew(packet, link: .central(centralUUID)) {
|
||||
continue
|
||||
|
||||
guard recordIngressIfNew(packet, link: .central(centralUUID), peerID: context.receivedFromPeerID) else {
|
||||
return
|
||||
}
|
||||
|
||||
handleReceivedPacket(packet, from: context.receivedFromPeerID)
|
||||
} else {
|
||||
if !recordIngressIfNew(packet, link: .central(centralUUID)) {
|
||||
continue
|
||||
}
|
||||
handleReceivedPacket(packet, from: context.receivedFromPeerID)
|
||||
}
|
||||
} else {
|
||||
// If buffer grows suspiciously large, reset to avoid memory leak
|
||||
if combined.count > TransportConfig.blePendingWriteBufferCapBytes { // cap for safety
|
||||
pendingWriteBuffers.removeValue(forKey: centralUUID)
|
||||
SecureLogger.warning("⚠️ Dropping oversized pending write buffer (\(combined.count) bytes) for central \(centralUUID)", category: .session)
|
||||
}
|
||||
// If this was a single short write and still failed, log the raw chunk for debugging
|
||||
if !hasMultiple, let only = sorted.first, let raw = only.value {
|
||||
let prefix = raw.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
|
||||
SecureLogger.error("❌ Failed to decode packet from central (len=\(raw.count), prefix=\(prefix))", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2860,6 +2845,10 @@ extension BLEService {
|
||||
}
|
||||
|
||||
private func forwardAlongRouteIfNeeded(_ packet: BitchatPacket) -> Bool {
|
||||
if PeerID(hexData: packet.recipientID) == myPeerID {
|
||||
return true
|
||||
}
|
||||
|
||||
guard let route = packet.route, !route.isEmpty else { return false }
|
||||
let myRoutingData = routingData(for: myPeerID) ?? (myPeerIDData.isEmpty ? nil : myPeerIDData)
|
||||
guard let selfData = myRoutingData else { return false }
|
||||
@@ -2924,6 +2913,27 @@ extension BLEService {
|
||||
}
|
||||
}
|
||||
|
||||
private func links(to peerID: PeerID?) -> Set<BLEIngressLinkID> {
|
||||
guard let peerID else { return [] }
|
||||
|
||||
let computeLinks = { () -> Set<BLEIngressLinkID> in
|
||||
var links: Set<BLEIngressLinkID> = []
|
||||
if let peripheralUUID = self.peerToPeripheralUUID[peerID] {
|
||||
links.insert(.peripheral(peripheralUUID))
|
||||
}
|
||||
for (centralUUID, mappedPeerID) in self.centralToPeerID where mappedPeerID == peerID {
|
||||
links.insert(.central(centralUUID))
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
|
||||
return computeLinks()
|
||||
} else {
|
||||
return bleQueue.sync { computeLinks() }
|
||||
}
|
||||
}
|
||||
|
||||
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
|
||||
service.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
||||
SecureLogger.debug("🔐 Noise session authenticated with \(peerID), fingerprint: \(fingerprint.prefix(16))...")
|
||||
@@ -2997,37 +3007,7 @@ extension BLEService {
|
||||
// MARK: Helpers: IDs, selection, and write backpressure
|
||||
|
||||
private func makeMessageID(for packet: BitchatPacket) -> String {
|
||||
let senderID = packet.senderID.hexEncodedString()
|
||||
let digestPrefix = packet.payload.sha256Hash().prefix(4).hexEncodedString()
|
||||
return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
|
||||
}
|
||||
|
||||
private func subsetSizeForFanout(_ n: Int) -> Int {
|
||||
guard n > 0 else { return 0 }
|
||||
if n <= 2 { return n }
|
||||
// approx ceil(log2(n)) + 1 without floating point
|
||||
var v = n - 1
|
||||
var bits = 0
|
||||
while v > 0 { v >>= 1; bits += 1 }
|
||||
return min(n, max(1, bits + 1))
|
||||
}
|
||||
|
||||
private func selectDeterministicSubset(ids: [String], k: Int, seed: String) -> Set<String> {
|
||||
guard k > 0 && ids.count > k else { return Set(ids) }
|
||||
// Stable order by SHA256(seed || "::" || id)
|
||||
var scored: [(score: [UInt8], id: String)] = []
|
||||
for id in ids {
|
||||
let msg = (seed + "::" + id).data(using: .utf8) ?? Data()
|
||||
let digest = Array(SHA256.hash(data: msg))
|
||||
scored.append((digest, id))
|
||||
}
|
||||
scored.sort { a, b in
|
||||
for i in 0..<min(a.score.count, b.score.count) {
|
||||
if a.score[i] != b.score[i] { return a.score[i] < b.score[i] }
|
||||
}
|
||||
return a.id < b.id
|
||||
}
|
||||
return Set(scored.prefix(k).map { $0.id })
|
||||
BLEIngressLinkRegistry.messageID(for: packet)
|
||||
}
|
||||
|
||||
private func priority(for packet: BitchatPacket, data: Data) -> BLEOutboundWritePriority {
|
||||
@@ -3116,37 +3096,7 @@ extension BLEService {
|
||||
|
||||
/// Periodically try to drain pending notifications as a backup mechanism
|
||||
private func drainPendingNotificationsIfPossible() {
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self,
|
||||
let characteristic = self.characteristic,
|
||||
!self.pendingNotifications.isEmpty else { return }
|
||||
|
||||
let pending = self.pendingNotifications
|
||||
self.pendingNotifications.removeAll()
|
||||
|
||||
var sentCount = 0
|
||||
for (index, (data, centrals)) in pending.enumerated() {
|
||||
let success: Bool
|
||||
if let centrals = centrals {
|
||||
success = self.peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: centrals) ?? false
|
||||
} else {
|
||||
success = self.peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: nil) ?? false
|
||||
}
|
||||
|
||||
if !success {
|
||||
// Re-queue this and all remaining items
|
||||
let remaining = pending.dropFirst(index)
|
||||
self.pendingNotifications.append(contentsOf: remaining)
|
||||
break
|
||||
} else {
|
||||
sentCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
if sentCount > 0 {
|
||||
SecureLogger.debug("🔄 Periodic drain: sent \(sentCount) pending notifications", category: .session)
|
||||
}
|
||||
}
|
||||
drainPendingNotifications(logPrefix: "🔄 Periodic drain: sent")
|
||||
}
|
||||
|
||||
/// Periodically try to drain pending writes for all connected peripherals
|
||||
@@ -3601,102 +3551,19 @@ extension BLEService {
|
||||
return
|
||||
}
|
||||
|
||||
// Minimum header: 8 bytes ID + 2 index + 2 total + 1 type
|
||||
guard packet.payload.count >= 13 else { return }
|
||||
guard let header = BLEFragmentHeader(packet: packet) else { return }
|
||||
|
||||
// Compute compact fragment key (sender: 8 bytes, id: 8 bytes), big-endian
|
||||
var senderU64: UInt64 = 0
|
||||
for b in packet.senderID.prefix(8) { senderU64 = (senderU64 << 8) | UInt64(b) }
|
||||
var fragU64: UInt64 = 0
|
||||
for b in packet.payload.prefix(8) { fragU64 = (fragU64 << 8) | UInt64(b) }
|
||||
// Parse big-endian UInt16 safely without alignment assumptions
|
||||
let idxHi = UInt16(packet.payload[8])
|
||||
let idxLo = UInt16(packet.payload[9])
|
||||
let index = Int((idxHi << 8) | idxLo)
|
||||
let totHi = UInt16(packet.payload[10])
|
||||
let totLo = UInt16(packet.payload[11])
|
||||
let total = Int((totHi << 8) | totLo)
|
||||
let originalType = packet.payload[12]
|
||||
let fragmentData = packet.payload.suffix(from: 13)
|
||||
|
||||
// Sanity checks - add reasonable upper bound on total to prevent DoS
|
||||
guard total > 0 && total <= 10000 && index >= 0 && index < total else { return }
|
||||
|
||||
let isBroadcastFragment: Bool = {
|
||||
guard let recipient = packet.recipientID else { return true }
|
||||
return recipient.count == 8 && recipient.allSatisfy { $0 == 0xFF }
|
||||
}()
|
||||
if isBroadcastFragment {
|
||||
if header.isBroadcastFragment {
|
||||
gossipSyncManager?.onPublicPacketSeen(packet)
|
||||
}
|
||||
|
||||
// Compute fragment key for this assembly
|
||||
let key = FragmentKey(sender: senderU64, id: fragU64)
|
||||
|
||||
// Critical section: Store fragment and check completion status
|
||||
var shouldReassemble: Bool = false
|
||||
var fragmentsToReassemble: [Int: Data]? = nil
|
||||
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
if incomingFragments[key] == nil {
|
||||
// Cap in-flight assemblies to prevent memory/battery blowups
|
||||
if incomingFragments.count >= maxInFlightAssemblies {
|
||||
// Evict the oldest assembly by timestamp
|
||||
if let oldest = fragmentMetadata.min(by: { $0.value.timestamp < $1.value.timestamp })?.key {
|
||||
incomingFragments.removeValue(forKey: oldest)
|
||||
fragmentMetadata.removeValue(forKey: oldest)
|
||||
}
|
||||
}
|
||||
incomingFragments[key] = [:]
|
||||
fragmentMetadata[key] = (originalType, total, Date())
|
||||
SecureLogger.debug("📦 Started fragment assembly id=\(String(format: "%016llx", fragU64)) total=\(total)", category: .session)
|
||||
let assemblyResult = collectionsQueue.sync(flags: .barrier) {
|
||||
fragmentAssemblyBuffer.append(header, maxInFlightAssemblies: maxInFlightAssemblies)
|
||||
}
|
||||
|
||||
// Check cumulative size before storing this fragment
|
||||
let currentSize = incomingFragments[key]?.values.reduce(0) { $0 + $1.count } ?? 0
|
||||
let assemblyLimit: Int = {
|
||||
if originalType == MessageType.fileTransfer.rawValue {
|
||||
// Allow headroom for TLV metadata and binary framing overhead.
|
||||
return FileTransferLimits.maxFramedFileBytes
|
||||
}
|
||||
return FileTransferLimits.maxPayloadBytes
|
||||
}()
|
||||
let projectedSize = currentSize + fragmentData.count
|
||||
guard projectedSize <= assemblyLimit else {
|
||||
// Exceeds size limit - evict this assembly
|
||||
SecureLogger.warning(
|
||||
"🚫 Fragment assembly exceeds size limit (\(projectedSize) bytes > \(assemblyLimit)), evicting. Type=\(originalType) Index=\(index)/\(total)",
|
||||
category: .security
|
||||
)
|
||||
incomingFragments.removeValue(forKey: key)
|
||||
fragmentMetadata.removeValue(forKey: key)
|
||||
shouldReassemble = false
|
||||
fragmentsToReassemble = nil
|
||||
return
|
||||
}
|
||||
logFragmentAssemblyResult(assemblyResult)
|
||||
|
||||
incomingFragments[key]?[index] = Data(fragmentData)
|
||||
SecureLogger.debug("📦 Fragment \(index + 1)/\(total) (len=\(fragmentData.count)) for id=\(String(format: "%016llx", fragU64))", category: .session)
|
||||
|
||||
// Check if complete
|
||||
if let fragments = incomingFragments[key], fragments.count == total {
|
||||
shouldReassemble = true
|
||||
fragmentsToReassemble = fragments
|
||||
} else {
|
||||
shouldReassemble = false
|
||||
fragmentsToReassemble = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Heavy work outside lock: reassemble and decode
|
||||
guard shouldReassemble, let fragments = fragmentsToReassemble else { return }
|
||||
|
||||
var reassembled = Data()
|
||||
for i in 0..<total {
|
||||
if let fragment = fragments[i] {
|
||||
reassembled.append(fragment)
|
||||
}
|
||||
}
|
||||
guard case let .complete(completedHeader, reassembled, _) = assemblyResult else { return }
|
||||
|
||||
// Decode the original packet bytes we reassembled, so flags/compression are preserved
|
||||
if var originalPacket = BinaryProtocol.decode(reassembled) {
|
||||
@@ -3706,18 +3573,37 @@ extension BLEService {
|
||||
if !validatePacket(originalPacket, from: innerSender) {
|
||||
// Cleanup below
|
||||
} else {
|
||||
SecureLogger.debug("✅ Reassembled packet id=\(String(format: "%016llx", fragU64)) type=\(originalPacket.type) bytes=\(reassembled.count)", category: .session)
|
||||
SecureLogger.debug("✅ Reassembled packet id=\(completedHeader.idLogString) type=\(originalPacket.type) bytes=\(reassembled.count)", category: .session)
|
||||
originalPacket.ttl = 0
|
||||
handleReceivedPacket(originalPacket, from: peerID)
|
||||
}
|
||||
} else {
|
||||
SecureLogger.error("❌ Failed to decode reassembled packet (type=\(originalType), total=\(total))", category: .session)
|
||||
SecureLogger.error("❌ Failed to decode reassembled packet (type=\(completedHeader.originalType), total=\(completedHeader.total))", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
// Critical section: Cleanup completed assembly
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
incomingFragments.removeValue(forKey: key)
|
||||
fragmentMetadata.removeValue(forKey: key)
|
||||
private func logFragmentAssemblyResult(_ result: BLEFragmentAssemblyBuffer.AppendResult) {
|
||||
func logStartedIfNeeded(header: BLEFragmentHeader, started: Bool) {
|
||||
if started {
|
||||
SecureLogger.debug("📦 Started fragment assembly id=\(header.idLogString) total=\(header.total)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
switch result {
|
||||
case let .stored(header, started):
|
||||
logStartedIfNeeded(header: header, started: started)
|
||||
SecureLogger.debug("📦 Fragment \(header.index + 1)/\(header.total) (len=\(header.fragmentData.count)) for id=\(header.idLogString)", category: .session)
|
||||
|
||||
case let .complete(header, _, started):
|
||||
logStartedIfNeeded(header: header, started: started)
|
||||
SecureLogger.debug("📦 Fragment \(header.index + 1)/\(header.total) (len=\(header.fragmentData.count)) for id=\(header.idLogString)", category: .session)
|
||||
|
||||
case let .oversized(header, projectedSize, limit, started):
|
||||
logStartedIfNeeded(header: header, started: started)
|
||||
SecureLogger.warning(
|
||||
"🚫 Fragment assembly exceeds size limit (\(projectedSize) bytes > \(limit)), evicting. Type=\(header.originalType) Index=\(header.index)/\(header.total)",
|
||||
category: .security
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3827,6 +3713,7 @@ extension BLEService {
|
||||
let decision = RelayController.decide(
|
||||
ttl: packet.ttl,
|
||||
senderIsSelf: senderID == myPeerID,
|
||||
recipientIsSelf: PeerID(hexData: packet.recipientID) == myPeerID,
|
||||
isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue) && (packet.recipientID != nil),
|
||||
isFragment: packet.type == MessageType.fragment.rawValue,
|
||||
@@ -4193,8 +4080,6 @@ extension BLEService {
|
||||
}
|
||||
|
||||
private func handleNoiseEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
SecureLogger.debug("🔐 handleNoiseEncrypted called for packet from \(peerID)")
|
||||
|
||||
guard let recipientID = PeerID(hexData: packet.recipientID) else {
|
||||
SecureLogger.warning("⚠️ Encrypted message has no recipient ID", category: .session)
|
||||
return
|
||||
@@ -4216,7 +4101,14 @@ extension BLEService {
|
||||
let payloadType = decrypted[0]
|
||||
let payloadData = decrypted.dropFirst()
|
||||
|
||||
switch NoisePayloadType(rawValue: payloadType) {
|
||||
guard let noisePayloadType = NoisePayloadType(rawValue: payloadType) else {
|
||||
SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)")
|
||||
return
|
||||
}
|
||||
|
||||
SecureLogger.debug("🔐 Decrypted noise payload type \(noisePayloadType.description) from \(peerID)", category: .session)
|
||||
|
||||
switch noisePayloadType {
|
||||
case .privateMessage:
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
notifyUI { [weak self] in
|
||||
@@ -4242,8 +4134,6 @@ extension BLEService {
|
||||
notifyUI { [weak self] in
|
||||
self?.deliverTransportEvent(.noisePayloadReceived(peerID: peerID, type: .verifyResponse, payload: Data(payloadData), timestamp: ts))
|
||||
}
|
||||
case .none:
|
||||
SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)")
|
||||
}
|
||||
} catch NoiseEncryptionError.sessionNotEstablished {
|
||||
// We received an encrypted message before establishing a session with this peer.
|
||||
@@ -4486,11 +4376,7 @@ extension BLEService {
|
||||
// Clean old fragments (> configured seconds old)
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
let cutoff = now.addingTimeInterval(-TransportConfig.bleFragmentLifetimeSeconds)
|
||||
let oldFragments = fragmentMetadata.filter { $0.value.timestamp < cutoff }.map { $0.key }
|
||||
for fragmentID in oldFragments {
|
||||
incomingFragments.removeValue(forKey: fragmentID)
|
||||
fragmentMetadata.removeValue(forKey: fragmentID)
|
||||
}
|
||||
fragmentAssemblyBuffer.removeExpired(before: cutoff)
|
||||
}
|
||||
|
||||
// Clean old connection timeout backoff entries (> window)
|
||||
@@ -4512,8 +4398,8 @@ extension BLEService {
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let cutoff = now.addingTimeInterval(-TransportConfig.bleIngressRecordLifetimeSeconds)
|
||||
if !self.ingressByMessageID.isEmpty {
|
||||
self.ingressByMessageID = self.ingressByMessageID.filter { $0.value.timestamp >= cutoff }
|
||||
if !self.ingressLinks.isEmpty {
|
||||
self.ingressLinks.prune(before: cutoff)
|
||||
}
|
||||
// Clean expired directed spooled items
|
||||
if !self.pendingDirectedRelays.isEmpty {
|
||||
|
||||
@@ -11,6 +11,7 @@ struct RelayDecision {
|
||||
struct RelayController {
|
||||
static func decide(ttl: UInt8,
|
||||
senderIsSelf: Bool,
|
||||
recipientIsSelf: Bool = false,
|
||||
isEncrypted: Bool,
|
||||
isDirectedEncrypted: Bool,
|
||||
isFragment: Bool,
|
||||
@@ -22,7 +23,7 @@ struct RelayController {
|
||||
let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
|
||||
|
||||
// Suppress obvious non-relays
|
||||
if ttlCap <= 1 || senderIsSelf {
|
||||
if ttlCap <= 1 || senderIsSelf || recipientIsSelf {
|
||||
return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0)
|
||||
}
|
||||
|
||||
|
||||
@@ -163,6 +163,36 @@ struct BLEServiceCoreTests {
|
||||
#expect(ble._test_recordIngressIfNew(packet: packet, linkID: "central-a"))
|
||||
#expect(!ble._test_recordIngressIfNew(packet: packet, linkID: "central-b"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func modifiedServices_rediscoverWhenBitChatServiceIsInvalidated() async throws {
|
||||
let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB")
|
||||
|
||||
#expect(BLEService._test_shouldRediscoverBitChatService(
|
||||
invalidatedServiceUUIDs: [BLEService.serviceUUID],
|
||||
cachedServiceUUIDs: [otherService]
|
||||
))
|
||||
}
|
||||
|
||||
@Test
|
||||
func modifiedServices_rediscoverWhenCachedServicesNoLongerIncludeBitChat() async throws {
|
||||
let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB")
|
||||
|
||||
#expect(BLEService._test_shouldRediscoverBitChatService(
|
||||
invalidatedServiceUUIDs: [otherService],
|
||||
cachedServiceUUIDs: [otherService]
|
||||
))
|
||||
}
|
||||
|
||||
@Test
|
||||
func modifiedServices_ignoreUnrelatedInvalidationWhenBitChatIsStillCached() async throws {
|
||||
let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB")
|
||||
|
||||
#expect(!BLEService._test_shouldRediscoverBitChatService(
|
||||
invalidatedServiceUUIDs: [otherService],
|
||||
cachedServiceUUIDs: [BLEService.serviceUUID, otherService]
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private func makeService() -> BLEService {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BLEFanoutSelectorTests {
|
||||
@Test
|
||||
func directedSendUsesAllNonIngressLinks() {
|
||||
let selection = BLEFanoutSelector.selectLinks(
|
||||
peripheralIDs: ["p1", "p2"],
|
||||
centralIDs: ["c1", "c2"],
|
||||
ingressLink: .central("c1"),
|
||||
directedPeerHint: PeerID(str: "1122334455667788"),
|
||||
packetType: MessageType.noiseEncrypted.rawValue,
|
||||
messageID: "message-1"
|
||||
)
|
||||
|
||||
#expect(selection.peripheralIDs == Set(["p1", "p2"]))
|
||||
#expect(selection.centralIDs == Set(["c2"]))
|
||||
}
|
||||
|
||||
@Test
|
||||
func directedSendExcludesAllLinksToIngressPeer() {
|
||||
let selection = BLEFanoutSelector.selectLinks(
|
||||
peripheralIDs: ["p1", "p2"],
|
||||
centralIDs: ["c1", "c2"],
|
||||
ingressLink: .central("c1"),
|
||||
excludedLinks: [.peripheral("p2"), .central("c2")],
|
||||
directedPeerHint: PeerID(str: "1122334455667788"),
|
||||
packetType: MessageType.noiseEncrypted.rawValue,
|
||||
messageID: "message-1"
|
||||
)
|
||||
|
||||
#expect(selection.peripheralIDs == Set(["p1"]))
|
||||
#expect(selection.centralIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func controlPacketsUseAllNonIngressLinks() {
|
||||
let selection = BLEFanoutSelector.selectLinks(
|
||||
peripheralIDs: ["p1", "p2", "p3"],
|
||||
centralIDs: ["c1", "c2", "c3"],
|
||||
ingressLink: .peripheral("p2"),
|
||||
directedPeerHint: nil,
|
||||
packetType: MessageType.requestSync.rawValue,
|
||||
messageID: "message-1"
|
||||
)
|
||||
|
||||
#expect(selection.peripheralIDs == Set(["p1", "p3"]))
|
||||
#expect(selection.centralIDs == Set(["c1", "c2", "c3"]))
|
||||
}
|
||||
|
||||
@Test
|
||||
func broadcastPacketsUseDeterministicSubsetAfterIngressExclusion() {
|
||||
let peripherals = (1...8).map { "p\($0)" }
|
||||
let centrals = (1...8).map { "c\($0)" }
|
||||
|
||||
let first = BLEFanoutSelector.selectLinks(
|
||||
peripheralIDs: peripherals,
|
||||
centralIDs: centrals,
|
||||
ingressLink: .peripheral("p4"),
|
||||
directedPeerHint: nil,
|
||||
packetType: MessageType.message.rawValue,
|
||||
messageID: "message-1"
|
||||
)
|
||||
let second = BLEFanoutSelector.selectLinks(
|
||||
peripheralIDs: peripherals,
|
||||
centralIDs: centrals,
|
||||
ingressLink: .peripheral("p4"),
|
||||
directedPeerHint: nil,
|
||||
packetType: MessageType.message.rawValue,
|
||||
messageID: "message-1"
|
||||
)
|
||||
|
||||
#expect(first == second)
|
||||
#expect(!first.peripheralIDs.contains("p4"))
|
||||
#expect(first.peripheralIDs.count == 4)
|
||||
#expect(first.centralIDs.count == 4)
|
||||
}
|
||||
|
||||
@Test
|
||||
func broadcastWithTwoLinksKeepsBothAfterIngressExclusion() {
|
||||
let selection = BLEFanoutSelector.selectLinks(
|
||||
peripheralIDs: ["p1", "p2"],
|
||||
centralIDs: ["c1", "c2"],
|
||||
ingressLink: nil,
|
||||
directedPeerHint: nil,
|
||||
packetType: MessageType.message.rawValue,
|
||||
messageID: "message-1"
|
||||
)
|
||||
|
||||
#expect(selection.peripheralIDs == Set(["p1", "p2"]))
|
||||
#expect(selection.centralIDs == Set(["c1", "c2"]))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BLEFragmentAssemblyBufferTests {
|
||||
@Test
|
||||
func appendCompletesOutOfOrderFragments() throws {
|
||||
var buffer = BLEFragmentAssemblyBuffer()
|
||||
let original = makePacket(payload: makePayload(count: 512))
|
||||
let fragmentPackets = try makeFragments(for: original, chunkSize: 128, fragmentID: Data(repeating: 0x01, count: 8))
|
||||
let headers = try fragmentPackets.reversed().map { try #require(BLEFragmentHeader(packet: $0)) }
|
||||
var result: BLEFragmentAssemblyBuffer.AppendResult?
|
||||
|
||||
for header in headers {
|
||||
result = buffer.append(header, maxInFlightAssemblies: 8)
|
||||
}
|
||||
|
||||
if case let .complete(header, reassembledData, started) = result {
|
||||
#expect(header.total == fragmentPackets.count)
|
||||
#expect(!started)
|
||||
#expect(BinaryProtocol.decode(reassembledData)?.payload == original.payload)
|
||||
} else {
|
||||
Issue.record("Expected final fragment to complete reassembly")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func appendDuplicateFragmentDoesNotCompleteEarly() throws {
|
||||
var buffer = BLEFragmentAssemblyBuffer()
|
||||
let original = makePacket(payload: makePayload(count: 384))
|
||||
let fragmentPackets = try makeFragments(for: original, chunkSize: 128, fragmentID: Data(repeating: 0x02, count: 8))
|
||||
let first = try #require(BLEFragmentHeader(packet: fragmentPackets[0]))
|
||||
let second = try #require(BLEFragmentHeader(packet: fragmentPackets[1]))
|
||||
|
||||
if case let .stored(_, started) = buffer.append(first, maxInFlightAssemblies: 8) {
|
||||
#expect(started)
|
||||
} else {
|
||||
Issue.record("Expected first fragment to be stored")
|
||||
}
|
||||
|
||||
if case .stored = buffer.append(first, maxInFlightAssemblies: 8) {
|
||||
// Duplicate should replace the same index, not increase completion count.
|
||||
} else {
|
||||
Issue.record("Expected duplicate fragment to remain incomplete")
|
||||
}
|
||||
|
||||
if case .stored = buffer.append(second, maxInFlightAssemblies: 8) {
|
||||
// Still missing at least one fragment.
|
||||
} else {
|
||||
Issue.record("Expected assembly to remain incomplete")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func appendEvictsOldestAssemblyWhenCapIsReached() throws {
|
||||
var buffer = BLEFragmentAssemblyBuffer()
|
||||
let oldPacket = makePacket(payload: makePayload(count: 256, seed: 1), timestamp: 1)
|
||||
let newPacket = makePacket(payload: makePayload(count: 256, seed: 2), timestamp: 2)
|
||||
let oldFragments = try makeFragments(for: oldPacket, chunkSize: 128, fragmentID: Data(repeating: 0x03, count: 8))
|
||||
let newFragments = try makeFragments(for: newPacket, chunkSize: 128, fragmentID: Data(repeating: 0x04, count: 8))
|
||||
let oldFirst = try #require(BLEFragmentHeader(packet: oldFragments[0]))
|
||||
let oldSecond = try #require(BLEFragmentHeader(packet: oldFragments[1]))
|
||||
let newFirst = try #require(BLEFragmentHeader(packet: newFragments[0]))
|
||||
let newSecond = try #require(BLEFragmentHeader(packet: newFragments[1]))
|
||||
|
||||
_ = buffer.append(oldFirst, maxInFlightAssemblies: 1, now: Date(timeIntervalSince1970: 1))
|
||||
_ = buffer.append(newFirst, maxInFlightAssemblies: 1, now: Date(timeIntervalSince1970: 2))
|
||||
|
||||
if case let .stored(_, started) = buffer.append(oldSecond, maxInFlightAssemblies: 1) {
|
||||
#expect(started)
|
||||
} else {
|
||||
Issue.record("Expected evicted assembly to restart when old fragment arrives")
|
||||
}
|
||||
|
||||
if case let .stored(_, started) = buffer.append(newSecond, maxInFlightAssemblies: 1) {
|
||||
#expect(started)
|
||||
} else {
|
||||
Issue.record("Expected new assembly to restart after old one consumed the only slot")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func appendOversizedAssemblyDropsPartialState() throws {
|
||||
var buffer = BLEFragmentAssemblyBuffer()
|
||||
let fragmentID = Data(repeating: 0x05, count: 8)
|
||||
let first = try #require(BLEFragmentHeader(packet: makeFragmentPacket(
|
||||
fragmentID: fragmentID,
|
||||
index: 0,
|
||||
total: 2,
|
||||
originalType: MessageType.message.rawValue,
|
||||
fragmentData: Data(repeating: 0x01, count: FileTransferLimits.maxPayloadBytes)
|
||||
)))
|
||||
let oversized = try #require(BLEFragmentHeader(packet: makeFragmentPacket(
|
||||
fragmentID: fragmentID,
|
||||
index: 1,
|
||||
total: 2,
|
||||
originalType: MessageType.message.rawValue,
|
||||
fragmentData: Data([0x02])
|
||||
)))
|
||||
|
||||
_ = buffer.append(first, maxInFlightAssemblies: 8)
|
||||
let result = buffer.append(oversized, maxInFlightAssemblies: 8)
|
||||
|
||||
if case let .oversized(_, projectedSize, limit, started) = result {
|
||||
#expect(projectedSize == FileTransferLimits.maxPayloadBytes + 1)
|
||||
#expect(limit == FileTransferLimits.maxPayloadBytes)
|
||||
#expect(!started)
|
||||
} else {
|
||||
Issue.record("Expected oversized fragment assembly to be evicted")
|
||||
}
|
||||
|
||||
if case let .stored(_, started) = buffer.append(oversized, maxInFlightAssemblies: 8) {
|
||||
#expect(started)
|
||||
} else {
|
||||
Issue.record("Expected later fragment to start a clean assembly")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func removeExpiredDropsOldAssemblies() throws {
|
||||
var buffer = BLEFragmentAssemblyBuffer()
|
||||
let packet = makePacket(payload: makePayload(count: 256))
|
||||
let fragments = try makeFragments(for: packet, chunkSize: 128, fragmentID: Data(repeating: 0x06, count: 8))
|
||||
let first = try #require(BLEFragmentHeader(packet: fragments[0]))
|
||||
let second = try #require(BLEFragmentHeader(packet: fragments[1]))
|
||||
|
||||
_ = buffer.append(first, maxInFlightAssemblies: 8, now: Date(timeIntervalSince1970: 1))
|
||||
#expect(buffer.removeExpired(before: Date(timeIntervalSince1970: 2)) == 1)
|
||||
|
||||
if case let .stored(_, started) = buffer.append(second, maxInFlightAssemblies: 8) {
|
||||
#expect(started)
|
||||
} else {
|
||||
Issue.record("Expected expired assembly to be gone")
|
||||
}
|
||||
}
|
||||
|
||||
private func makePacket(payload: Data, timestamp: UInt64 = 0x0102030405) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77]),
|
||||
recipientID: nil,
|
||||
timestamp: timestamp,
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: 3
|
||||
)
|
||||
}
|
||||
|
||||
private func makePayload(count: Int, seed: UInt64 = 0x1234ABCD) -> Data {
|
||||
var state = seed
|
||||
return Data((0..<count).map { _ in
|
||||
state = state &* 6364136223846793005 &+ 1442695040888963407
|
||||
return UInt8(truncatingIfNeeded: state >> 32)
|
||||
})
|
||||
}
|
||||
|
||||
private func makeFragments(for packet: BitchatPacket, chunkSize: Int, fragmentID: Data) throws -> [BitchatPacket] {
|
||||
let fullData = try #require(packet.toBinaryData(padding: false))
|
||||
let chunks = stride(from: 0, to: fullData.count, by: chunkSize).map { offset in
|
||||
Data(fullData[offset..<min(offset + chunkSize, fullData.count)])
|
||||
}
|
||||
|
||||
return chunks.enumerated().map { index, chunk in
|
||||
makeFragmentPacket(
|
||||
fragmentID: fragmentID,
|
||||
index: index,
|
||||
total: chunks.count,
|
||||
originalType: packet.type,
|
||||
fragmentData: chunk,
|
||||
senderID: packet.senderID,
|
||||
recipientID: packet.recipientID,
|
||||
timestamp: packet.timestamp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeFragmentPacket(
|
||||
fragmentID: Data,
|
||||
index: Int,
|
||||
total: Int,
|
||||
originalType: UInt8,
|
||||
fragmentData: Data,
|
||||
senderID: Data = Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77]),
|
||||
recipientID: Data? = nil,
|
||||
timestamp: UInt64 = 0x0102030405
|
||||
) -> BitchatPacket {
|
||||
var payload = Data()
|
||||
payload.append(fragmentID)
|
||||
payload.append(contentsOf: withUnsafeBytes(of: UInt16(index).bigEndian) { Data($0) })
|
||||
payload.append(contentsOf: withUnsafeBytes(of: UInt16(total).bigEndian) { Data($0) })
|
||||
payload.append(originalType)
|
||||
payload.append(fragmentData)
|
||||
|
||||
return BitchatPacket(
|
||||
type: MessageType.fragment.rawValue,
|
||||
senderID: senderID,
|
||||
recipientID: recipientID,
|
||||
timestamp: timestamp,
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: 3
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BLEInboundWriteBufferTests {
|
||||
@Test
|
||||
func appendDecodesPacketAcrossChunkedWrites() throws {
|
||||
var buffer = BLEInboundWriteBuffer()
|
||||
let packet = makePacket()
|
||||
let frame = try #require(packet.toBinaryData(padding: false))
|
||||
let splitIndex = max(1, frame.count / 2)
|
||||
|
||||
let firstResult = buffer.append(
|
||||
chunks: [BLEInboundWriteChunk(offset: 0, data: frame.prefix(splitIndex))],
|
||||
for: "central-1",
|
||||
capBytes: 1024
|
||||
)
|
||||
|
||||
if case let .waiting(metadata) = firstResult {
|
||||
#expect(metadata.accumulatedBytes == splitIndex)
|
||||
#expect(metadata.appendedBytes == splitIndex)
|
||||
#expect(metadata.offsets == [0])
|
||||
#expect(metadata.packetType == MessageType.message.rawValue)
|
||||
} else {
|
||||
Issue.record("Expected first chunk to wait for more data")
|
||||
}
|
||||
|
||||
let secondResult = buffer.append(
|
||||
chunks: [BLEInboundWriteChunk(offset: splitIndex, data: frame.suffix(from: splitIndex))],
|
||||
for: "central-1",
|
||||
capBytes: 1024
|
||||
)
|
||||
|
||||
if case let .decoded(decoded, metadata) = secondResult {
|
||||
#expect(decoded.type == packet.type)
|
||||
#expect(decoded.senderID == packet.senderID)
|
||||
#expect(decoded.payload == packet.payload)
|
||||
#expect(metadata.accumulatedBytes == frame.count)
|
||||
#expect(metadata.offsets == [splitIndex])
|
||||
} else {
|
||||
Issue.record("Expected complete frame to decode")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func appendMergesMultipleOffsetChunksInOneCall() throws {
|
||||
var buffer = BLEInboundWriteBuffer()
|
||||
let packet = makePacket(timestamp: 0xABC)
|
||||
let frame = try #require(packet.toBinaryData(padding: false))
|
||||
let splitIndex = max(1, frame.count / 3)
|
||||
|
||||
let result = buffer.append(
|
||||
chunks: [
|
||||
BLEInboundWriteChunk(offset: 0, data: frame.prefix(splitIndex)),
|
||||
BLEInboundWriteChunk(offset: splitIndex, data: frame.suffix(from: splitIndex))
|
||||
],
|
||||
for: "central-1",
|
||||
capBytes: 1024
|
||||
)
|
||||
|
||||
if case let .decoded(decoded, metadata) = result {
|
||||
#expect(decoded.timestamp == packet.timestamp)
|
||||
#expect(metadata.appendedBytes == frame.count)
|
||||
#expect(metadata.offsets == [0, splitIndex])
|
||||
} else {
|
||||
Issue.record("Expected merged chunks to decode")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func appendDropsOversizedBufferAndAllowsLaterDecode() throws {
|
||||
var buffer = BLEInboundWriteBuffer()
|
||||
let oversized = buffer.append(
|
||||
chunks: [BLEInboundWriteChunk(offset: 0, data: Data(repeating: 0xAA, count: 8))],
|
||||
for: "central-1",
|
||||
capBytes: 4
|
||||
)
|
||||
|
||||
if case let .oversized(metadata) = oversized {
|
||||
#expect(metadata.accumulatedBytes == 8)
|
||||
#expect(metadata.appendedBytes == 8)
|
||||
} else {
|
||||
Issue.record("Expected oversized buffer to be dropped")
|
||||
}
|
||||
|
||||
let packet = makePacket(timestamp: 0xDEF)
|
||||
let frame = try #require(packet.toBinaryData(padding: false))
|
||||
let decoded = buffer.append(
|
||||
chunks: [BLEInboundWriteChunk(offset: 0, data: frame)],
|
||||
for: "central-1",
|
||||
capBytes: 1024
|
||||
)
|
||||
|
||||
if case let .decoded(decodedPacket, _) = decoded {
|
||||
#expect(decodedPacket.timestamp == packet.timestamp)
|
||||
} else {
|
||||
Issue.record("Expected later clean frame to decode")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func removeAllClearsPartialWrites() throws {
|
||||
var buffer = BLEInboundWriteBuffer()
|
||||
let packet = makePacket()
|
||||
let frame = try #require(packet.toBinaryData(padding: false))
|
||||
let splitIndex = max(1, frame.count / 2)
|
||||
|
||||
_ = buffer.append(
|
||||
chunks: [BLEInboundWriteChunk(offset: 0, data: frame.prefix(splitIndex))],
|
||||
for: "central-1",
|
||||
capBytes: 1024
|
||||
)
|
||||
buffer.removeAll()
|
||||
|
||||
let result = buffer.append(
|
||||
chunks: [BLEInboundWriteChunk(offset: splitIndex, data: frame.suffix(from: splitIndex))],
|
||||
for: "central-1",
|
||||
capBytes: 1024
|
||||
)
|
||||
|
||||
if case .waiting = result {
|
||||
// Expected: the first half was cleared, so the second half alone cannot decode.
|
||||
} else {
|
||||
Issue.record("Expected buffer reset to discard the earlier partial write")
|
||||
}
|
||||
}
|
||||
|
||||
private func makePacket(timestamp: UInt64 = 0x0102030405) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77]),
|
||||
recipientID: nil,
|
||||
timestamp: timestamp,
|
||||
payload: Data([0xDE, 0xAD, 0xBE, 0xEF]),
|
||||
signature: nil,
|
||||
ttl: 3
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
struct BLEIngressLinkRegistryTests {
|
||||
@Test
|
||||
func recordIfNewSuppressesDuplicateWithinLifetime() {
|
||||
var registry = BLEIngressLinkRegistry()
|
||||
let packet = makePacket(sender: PeerID(str: "1122334455667788"), timestamp: 1)
|
||||
let peer = PeerID(str: "1122334455667788")
|
||||
let now = Date()
|
||||
|
||||
let firstRecord = registry.recordIfNew(packet, link: .central("central-a"), peerID: peer, now: now, lifetime: 3.0)
|
||||
let duplicateRecord = registry.recordIfNew(packet, link: .peripheral("peripheral-b"), peerID: peer, now: now.addingTimeInterval(1.0), lifetime: 3.0)
|
||||
|
||||
#expect(firstRecord)
|
||||
#expect(!duplicateRecord)
|
||||
#expect(registry.link(for: packet) == .central("central-a"))
|
||||
#expect(registry.peerID(for: packet) == peer)
|
||||
}
|
||||
|
||||
@Test
|
||||
func recordIfNewAllowsExpiredDuplicateAndUpdatesLink() {
|
||||
var registry = BLEIngressLinkRegistry()
|
||||
let packet = makePacket(sender: PeerID(str: "1122334455667788"), timestamp: 1)
|
||||
let peer = PeerID(str: "1122334455667788")
|
||||
let now = Date()
|
||||
|
||||
let firstRecord = registry.recordIfNew(packet, link: .central("central-a"), peerID: peer, now: now, lifetime: 3.0)
|
||||
let expiredRecord = registry.recordIfNew(packet, link: .peripheral("peripheral-b"), peerID: peer, now: now.addingTimeInterval(4.0), lifetime: 3.0)
|
||||
|
||||
#expect(firstRecord)
|
||||
#expect(expiredRecord)
|
||||
#expect(registry.link(for: packet) == .peripheral("peripheral-b"))
|
||||
#expect(registry.peerID(for: packet) == peer)
|
||||
}
|
||||
|
||||
@Test
|
||||
func pruneRemovesExpiredIngressLinks() {
|
||||
var registry = BLEIngressLinkRegistry()
|
||||
let packet = makePacket(sender: PeerID(str: "1122334455667788"), timestamp: 1)
|
||||
let peer = PeerID(str: "1122334455667788")
|
||||
let now = Date()
|
||||
|
||||
let firstRecord = registry.recordIfNew(packet, link: .central("central-a"), peerID: peer, now: now, lifetime: 3.0)
|
||||
#expect(firstRecord)
|
||||
registry.prune(before: now.addingTimeInterval(4.0))
|
||||
|
||||
#expect(registry.isEmpty)
|
||||
#expect(registry.link(for: packet) == nil)
|
||||
#expect(registry.peerID(for: packet) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func packetContextRejectsSelfLoopback() {
|
||||
let localPeer = PeerID(str: "1122334455667788")
|
||||
let packet = makePacket(sender: localPeer, timestamp: 1)
|
||||
|
||||
let result = BLEIngressLinkRegistry.packetContext(
|
||||
for: packet,
|
||||
claimedSenderID: localPeer,
|
||||
boundPeerID: nil,
|
||||
localPeerID: localPeer,
|
||||
directAnnounceTTL: 7
|
||||
)
|
||||
|
||||
#expect(result == .failure(.selfLoopback(packetType: MessageType.message.rawValue)))
|
||||
}
|
||||
|
||||
@Test
|
||||
func packetContextAllowsRelayedSenderOnBoundLink() throws {
|
||||
let localPeer = PeerID(str: "0011223344556677")
|
||||
let boundPeer = PeerID(str: "1122334455667788")
|
||||
let relayedSender = PeerID(str: "8899aabbccddeeff")
|
||||
let packet = makePacket(sender: relayedSender, timestamp: 1)
|
||||
|
||||
let context = try #require(trySuccess(BLEIngressLinkRegistry.packetContext(
|
||||
for: packet,
|
||||
claimedSenderID: relayedSender,
|
||||
boundPeerID: boundPeer,
|
||||
localPeerID: localPeer,
|
||||
directAnnounceTTL: 7
|
||||
)))
|
||||
|
||||
#expect(context.receivedFromPeerID == boundPeer)
|
||||
#expect(context.validationPeerID == relayedSender)
|
||||
}
|
||||
|
||||
@Test
|
||||
func packetContextRejectsDirectAnnounceMismatchOnBoundLink() {
|
||||
let localPeer = PeerID(str: "0011223344556677")
|
||||
let boundPeer = PeerID(str: "1122334455667788")
|
||||
let claimedPeer = PeerID(str: "8899aabbccddeeff")
|
||||
let packet = makeAnnouncePacket(sender: claimedPeer, ttl: 7)
|
||||
|
||||
let result = BLEIngressLinkRegistry.packetContext(
|
||||
for: packet,
|
||||
claimedSenderID: claimedPeer,
|
||||
boundPeerID: boundPeer,
|
||||
localPeerID: localPeer,
|
||||
directAnnounceTTL: 7
|
||||
)
|
||||
|
||||
#expect(result == .failure(.directSenderMismatch(boundPeerID: boundPeer, claimedSenderID: claimedPeer)))
|
||||
}
|
||||
|
||||
@Test
|
||||
func packetContextUsesBoundPeerForRSRValidation() throws {
|
||||
let localPeer = PeerID(str: "0011223344556677")
|
||||
let boundPeer = PeerID(str: "1122334455667788")
|
||||
let relayedSender = PeerID(str: "8899aabbccddeeff")
|
||||
var packet = makePacket(sender: relayedSender, timestamp: 1)
|
||||
packet.isRSR = true
|
||||
|
||||
let context = try #require(trySuccess(BLEIngressLinkRegistry.packetContext(
|
||||
for: packet,
|
||||
claimedSenderID: relayedSender,
|
||||
boundPeerID: boundPeer,
|
||||
localPeerID: localPeer,
|
||||
directAnnounceTTL: 7
|
||||
)))
|
||||
|
||||
#expect(context.receivedFromPeerID == boundPeer)
|
||||
#expect(context.validationPeerID == boundPeer)
|
||||
}
|
||||
|
||||
@Test
|
||||
func packetContextAllowsSelfAuthoredRSRWithTTLZeroFromBoundPeer() throws {
|
||||
let localPeer = PeerID(str: "0011223344556677")
|
||||
let boundPeer = PeerID(str: "1122334455667788")
|
||||
var packet = makePacket(sender: localPeer, timestamp: 1)
|
||||
packet.isRSR = true
|
||||
packet.ttl = 0
|
||||
|
||||
let context = try #require(trySuccess(BLEIngressLinkRegistry.packetContext(
|
||||
for: packet,
|
||||
claimedSenderID: localPeer,
|
||||
boundPeerID: boundPeer,
|
||||
localPeerID: localPeer,
|
||||
directAnnounceTTL: 7
|
||||
)))
|
||||
|
||||
#expect(context.receivedFromPeerID == boundPeer)
|
||||
#expect(context.validationPeerID == boundPeer)
|
||||
}
|
||||
}
|
||||
|
||||
private func makePacket(sender: PeerID, timestamp: UInt64) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: Data(hexString: sender.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: timestamp,
|
||||
payload: Data("hello".utf8),
|
||||
signature: nil,
|
||||
ttl: 3
|
||||
)
|
||||
}
|
||||
|
||||
private func makeAnnouncePacket(sender: PeerID, ttl: UInt8) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.announce.rawValue,
|
||||
senderID: Data(hexString: sender.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: 1,
|
||||
payload: Data(),
|
||||
signature: nil,
|
||||
ttl: ttl
|
||||
)
|
||||
}
|
||||
|
||||
private func trySuccess(_ result: Result<BLEIngressPacketContext, BLEIngressRejection>) -> BLEIngressPacketContext? {
|
||||
guard case .success(let context) = result else {
|
||||
return nil
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BLEOutboundNotificationBufferTests {
|
||||
@Test
|
||||
func enqueueStoresNotificationsUntilTaken() {
|
||||
var buffer = BLEOutboundNotificationBuffer<String>()
|
||||
|
||||
let result = buffer.enqueue(
|
||||
data: Data([0x01]),
|
||||
targets: ["central-1"],
|
||||
capCount: 2
|
||||
)
|
||||
|
||||
if case let .enqueued(count) = result {
|
||||
#expect(count == 1)
|
||||
} else {
|
||||
Issue.record("Expected notification to be enqueued")
|
||||
}
|
||||
|
||||
let pending = buffer.takeAll()
|
||||
|
||||
#expect(pending.count == 1)
|
||||
#expect(pending.first?.data == Data([0x01]))
|
||||
#expect(pending.first?.targets == ["central-1"])
|
||||
#expect(buffer.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func enqueueRejectsWhenCapIsFull() {
|
||||
var buffer = BLEOutboundNotificationBuffer<String>()
|
||||
|
||||
_ = buffer.enqueue(data: Data([0x01]), targets: nil, capCount: 1)
|
||||
let result = buffer.enqueue(data: Data([0x02]), targets: nil, capCount: 1)
|
||||
|
||||
if case let .full(count) = result {
|
||||
#expect(count == 1)
|
||||
} else {
|
||||
Issue.record("Expected full notification buffer")
|
||||
}
|
||||
|
||||
#expect(buffer.count == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func prependRestoresUnsentNotificationsAheadOfNewerItems() {
|
||||
var buffer = BLEOutboundNotificationBuffer<String>()
|
||||
let unsent = [
|
||||
BLEPendingNotification(data: Data([0x01]), targets: ["old"])
|
||||
]
|
||||
|
||||
_ = buffer.enqueue(data: Data([0x02]), targets: ["new"], capCount: 4)
|
||||
buffer.prepend(unsent)
|
||||
|
||||
let pending = buffer.takeAll()
|
||||
|
||||
#expect(pending.map { Int($0.data.first ?? 0) } == [0x01, 0x02])
|
||||
}
|
||||
|
||||
@Test
|
||||
func removeAllClearsBufferedNotifications() {
|
||||
var buffer = BLEOutboundNotificationBuffer<String>()
|
||||
|
||||
_ = buffer.enqueue(data: Data([0x01]), targets: nil, capCount: 4)
|
||||
buffer.removeAll()
|
||||
|
||||
#expect(buffer.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -289,9 +289,10 @@ struct NostrTransportTests {
|
||||
transport.sendReadReceipt(first, to: fullPeerID)
|
||||
transport.sendReadReceipt(second, to: fullPeerID)
|
||||
|
||||
let sentFirst = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 1.5)
|
||||
let readReceiptTimeout: TimeInterval = 5.0
|
||||
let sentFirst = await TestHelpers.waitUntil({ probe.sentEvents.count >= 1 }, timeout: readReceiptTimeout)
|
||||
try #require(sentFirst, "Expected first queued read receipt event")
|
||||
let scheduledThrottle = await TestHelpers.waitUntil({ probe.scheduledActionCount == 1 }, timeout: 1.5)
|
||||
let scheduledThrottle = await TestHelpers.waitUntil({ probe.scheduledActionCount == 1 }, timeout: readReceiptTimeout)
|
||||
try #require(scheduledThrottle, "Expected queued throttle action after first read receipt")
|
||||
let firstEvent = try #require(probe.sentEvents.first, "Expected first queued read receipt event")
|
||||
let firstPayload = try decodeEmbeddedPayload(from: firstEvent, recipient: recipient).payload
|
||||
@@ -300,7 +301,7 @@ struct NostrTransportTests {
|
||||
|
||||
try #require(probe.runNextScheduledAction(), "Expected queued throttle action after first read receipt")
|
||||
|
||||
let sentSecond = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: 1.5)
|
||||
let sentSecond = await TestHelpers.waitUntil({ probe.sentEvents.count >= 2 }, timeout: readReceiptTimeout)
|
||||
try #require(sentSecond, "Expected second read receipt after running throttle action")
|
||||
let secondEvent = try #require(probe.sentEvents.last, "Expected second queued read receipt event")
|
||||
let secondPayload = try decodeEmbeddedPayload(from: secondEvent, recipient: recipient).payload
|
||||
|
||||
@@ -50,6 +50,26 @@ struct RelayControllerTests {
|
||||
#expect(decision.delayMs >= 10 && decision.delayMs <= 35)
|
||||
}
|
||||
|
||||
@Test
|
||||
func localRecipientDoesNotRelayDirectedTraffic() async {
|
||||
let decision = RelayController.decide(
|
||||
ttl: 7,
|
||||
senderIsSelf: false,
|
||||
recipientIsSelf: true,
|
||||
isEncrypted: true,
|
||||
isDirectedEncrypted: true,
|
||||
isFragment: false,
|
||||
isDirectedFragment: false,
|
||||
isHandshake: false,
|
||||
isAnnounce: false,
|
||||
degree: 3,
|
||||
highDegreeThreshold: TransportConfig.bleHighDegreeThreshold
|
||||
)
|
||||
|
||||
#expect(!decision.shouldRelay)
|
||||
#expect(decision.newTTL == 7)
|
||||
}
|
||||
|
||||
@Test
|
||||
func fragment_relaysWithFragmentCap() async {
|
||||
let decision = RelayController.decide(
|
||||
|
||||
@@ -374,6 +374,7 @@ final class SecureIdentityStateManagerTests: XCTestCase {
|
||||
XCTAssertTrue(manager.getFavorites().isEmpty)
|
||||
XCTAssertTrue(manager.getVerifiedFingerprints().isEmpty)
|
||||
XCTAssertTrue(manager.getBlockedNostrPubkeys().isEmpty)
|
||||
XCTAssertNil(keychain.getIdentityKey(forKey: "bitchat.identityCache.v2"))
|
||||
}
|
||||
|
||||
func test_clearAllIdentityData_removesCachedState() async {
|
||||
|
||||
@@ -44,3 +44,5 @@ This branch starts a larger simplification effort focused on performance, reliab
|
||||
The Bluetooth architecture branch begins step 2 by adding a typed `TransportEvent` boundary while preserving the legacy `BitchatDelegate` bridge. New transport code should emit typed events first, with delegate forwarding used only as a compatibility adapter during migration.
|
||||
|
||||
The branch also starts carving performance-sensitive BLE scheduling state out of `BLEService`: pending write backpressure now lives in `BLEOutboundWriteBuffer`, giving the outbound hot path a focused, unit-tested component before deeper fragmentation and link-scheduler work.
|
||||
|
||||
The next transport slice continues that path by extracting ingress link memory and outbound fanout selection. `BLEIngressLinkRegistry` now owns duplicate/last-hop tracking, ingress peer memory, and direct-link sender binding decisions, while `BLEFanoutSelector` owns deterministic broadcast subsetting and ingress-peer/link exclusion. `BLEService` still coordinates CoreBluetooth callbacks, but these hot-path decisions are now pure, covered units instead of inline dictionary logic.
|
||||
|
||||
Reference in New Issue
Block a user