[codex] Extract BLE service policy helpers (#1321)

* Extract BLE service policy helpers

* Stabilize image media transfer test

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2026-06-01 14:16:44 +02:00
committed by GitHub
co-authored by jack
parent ffa0d7aa4f
commit 193cfdc06a
16 changed files with 1565 additions and 486 deletions
@@ -0,0 +1,76 @@
import BitFoundation
import Foundation
enum BLEIngressPacketGuard {
enum Rejection: Error, Equatable {
case selfLoopback(packetType: UInt8)
case directSenderMismatch(boundPeerID: PeerID, claimedSenderID: PeerID)
case invalidRSR(peerID: PeerID)
case timestampSkew(peerID: PeerID, skewMs: UInt64, maxSkewMs: UInt64)
}
static func evaluate(
packet: BitchatPacket,
claimedSenderID: PeerID,
boundPeerID: PeerID?,
localPeerID: PeerID,
directAnnounceTTL: UInt8,
nowMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000),
maxTimestampSkewMs: UInt64 = 120_000,
isValidSyncResponse: (PeerID) -> Bool
) -> Result<BLEIngressPacketContext, Rejection> {
let contextResult = BLEIngressLinkRegistry.packetContext(
for: packet,
claimedSenderID: claimedSenderID,
boundPeerID: boundPeerID,
localPeerID: localPeerID,
directAnnounceTTL: directAnnounceTTL
)
let context: BLEIngressPacketContext
switch contextResult {
case .success(let acceptedContext):
context = acceptedContext
case .failure(.selfLoopback(let packetType)):
return .failure(.selfLoopback(packetType: packetType))
case .failure(.directSenderMismatch(let boundPeerID, let claimedSenderID)):
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
}
switch validatePayload(
packet,
from: context.validationPeerID,
nowMs: nowMs,
maxTimestampSkewMs: maxTimestampSkewMs,
isValidSyncResponse: isValidSyncResponse
) {
case .success:
return .success(context)
case .failure(let rejection):
return .failure(rejection)
}
}
static func validatePayload(
_ packet: BitchatPacket,
from peerID: PeerID,
nowMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000),
maxTimestampSkewMs: UInt64 = 120_000,
isValidSyncResponse: (PeerID) -> Bool
) -> Result<Void, Rejection> {
if packet.isRSR {
guard isValidSyncResponse(peerID) else {
return .failure(.invalidRSR(peerID: peerID))
}
return .success(())
}
let packetTime = packet.timestamp
let skew = packetTime > nowMs ? packetTime - nowMs : nowMs - packetTime
guard skew <= maxTimestampSkewMs else {
return .failure(.timestampSkew(peerID: peerID, skewMs: skew, maxSkewMs: maxTimestampSkewMs))
}
return .success(())
}
}
@@ -0,0 +1,33 @@
import Foundation
final class BLELogRateLimiter {
private let defaultMinimumInterval: TimeInterval
private let queue = DispatchQueue(label: "chat.bitchat.ble.log-rate-limiter")
private var lastLogTimeByKey: [String: Date] = [:]
init(defaultMinimumInterval: TimeInterval) {
self.defaultMinimumInterval = defaultMinimumInterval
}
func shouldLog(
key: String,
now: Date = Date(),
minimumInterval: TimeInterval? = nil
) -> Bool {
queue.sync {
let interval = minimumInterval ?? defaultMinimumInterval
if let lastLogTime = lastLogTimeByKey[key],
now.timeIntervalSince(lastLogTime) < interval {
return false
}
lastLogTimeByKey[key] = now
return true
}
}
func removeAll() {
queue.sync {
lastLogTimeByKey.removeAll()
}
}
}
@@ -0,0 +1,137 @@
import BitFoundation
import Foundation
struct BLEOutboundFragmentPlan {
let fragmentPackets: [BitchatPacket]
let fragmentVersion: UInt8
let chunkSize: Int
let spacingMs: Int
var totalFragments: Int {
fragmentPackets.count
}
var shouldPauseScanning: Bool {
totalFragments > 4
}
}
enum BLEOutboundFragmentPlanner {
private static let minimumChunkSize = 64
private static let fragmentIDLength = 8
static func makePlan(
for request: BLEOutboundFragmentTransferRequest,
defaultChunkSize: Int,
bleMaxMTU: Int,
fragmentID: Data = randomFragmentID()
) -> BLEOutboundFragmentPlan? {
guard fragmentID.count == fragmentIDLength,
let fullData = request.packet.toBinaryData(padding: request.pad) else {
return nil
}
let sizing = sizingPolicy(
for: request.packet,
requestedMaxChunk: request.maxChunk,
defaultChunkSize: defaultChunkSize,
bleMaxMTU: bleMaxMTU
)
let chunks = stride(from: 0, to: fullData.count, by: sizing.chunkSize).map { offset in
Data(fullData[offset..<min(offset + sizing.chunkSize, fullData.count)])
}
guard !chunks.isEmpty else { return nil }
let fragmentRecipient: Data? = {
if let directedPeer = request.directedPeer {
return Data(hexString: directedPeer.id)
}
return request.packet.recipientID
}()
let fragmentPackets = chunks.enumerated().map { index, chunk in
makeFragmentPacket(
original: request.packet,
fragmentID: fragmentID,
index: index,
total: chunks.count,
fragmentData: chunk,
fragmentRecipient: fragmentRecipient,
fragmentVersion: sizing.fragmentVersion
)
}
return BLEOutboundFragmentPlan(
fragmentPackets: fragmentPackets,
fragmentVersion: sizing.fragmentVersion,
chunkSize: sizing.chunkSize,
spacingMs: spacingMs(for: request)
)
}
private static func sizingPolicy(
for packet: BitchatPacket,
requestedMaxChunk: Int?,
defaultChunkSize: Int,
bleMaxMTU: Int
) -> (fragmentVersion: UInt8, chunkSize: Int) {
var fragmentVersion: UInt8 = 1
var calculatedChunk = defaultChunkSize
if let route = packet.route, !route.isEmpty {
fragmentVersion = 2
let routeSize = 1 + (route.count * 8)
let overhead = 16 + 8 + 8 + routeSize + 13 + 16
calculatedChunk = max(minimumChunkSize, bleMaxMTU - overhead)
}
return (
fragmentVersion: fragmentVersion,
chunkSize: max(minimumChunkSize, requestedMaxChunk ?? calculatedChunk)
)
}
private static func makeFragmentPacket(
original packet: BitchatPacket,
fragmentID: Data,
index: Int,
total: Int,
fragmentData: Data,
fragmentRecipient: Data?,
fragmentVersion: UInt8
) -> 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(packet.type)
payload.append(fragmentData)
return BitchatPacket(
type: MessageType.fragment.rawValue,
senderID: packet.senderID,
recipientID: fragmentRecipient,
timestamp: packet.timestamp,
payload: payload,
signature: nil,
ttl: packet.ttl,
version: fragmentVersion,
route: packet.route,
isRSR: packet.isRSR
)
}
private static func spacingMs(for request: BLEOutboundFragmentTransferRequest) -> Int {
if request.directedPeer != nil || request.packet.recipientID != nil {
return TransportConfig.bleFragmentSpacingDirectedMs
}
return TransportConfig.bleFragmentSpacingMs
}
private static func randomFragmentID() -> Data {
Data((0..<fragmentIDLength).map { _ in UInt8.random(in: 0...255) })
}
}
+211
View File
@@ -0,0 +1,211 @@
import BitFoundation
import Foundation
struct BLEPeerInfo: Equatable {
let peerID: PeerID
var nickname: String
var isConnected: Bool
var noisePublicKey: Data?
var signingPublicKey: Data?
var isVerifiedNickname: Bool
var lastSeen: Date
}
struct BLEPeerAnnounceUpdate: Equatable {
let isNewPeer: Bool
let wasDisconnected: Bool
let previousNickname: String?
}
struct BLEPeerLinkPresence: Equatable {
var hasPeripheral: Bool
var hasCentral: Bool
}
struct BLERemovedPeer: Equatable {
let peerID: PeerID
let nickname: String
}
struct BLEPeerConnectivityChanges: Equatable {
var disconnectedPeerIDs: [PeerID] = []
var removedPeers: [BLERemovedPeer] = []
}
struct BLEPeerRegistry {
private var peers: [PeerID: BLEPeerInfo] = [:]
var isEmpty: Bool {
peers.isEmpty
}
var count: Int {
peers.count
}
var peerIDs: [PeerID] {
Array(peers.keys)
}
var connectedCount: Int {
peers.values.filter(\.isConnected).count
}
var connectedPeerIDs: [PeerID] {
peers.values.compactMap { $0.isConnected ? $0.peerID : nil }
}
var connectedRoutingData: [Data] {
peers.values.filter(\.isConnected).compactMap { $0.peerID.routingData }
}
var snapshotByID: [PeerID: BLEPeerInfo] {
peers
}
mutating func removeAll() {
peers.removeAll()
}
func info(for peerID: PeerID) -> BLEPeerInfo? {
peers[peerID]
}
mutating func upsert(_ info: BLEPeerInfo) {
peers[info.peerID] = info
}
@discardableResult
mutating func remove(_ peerID: PeerID) -> BLEPeerInfo? {
peers.removeValue(forKey: peerID)
}
func isConnected(_ peerID: PeerID) -> Bool {
peers[peerID.toShort()]?.isConnected ?? false
}
func isReachable(_ peerID: PeerID, now: Date) -> Bool {
let shortID = peerID.toShort()
let meshAttached = connectedCount > 0
guard let info = peers[shortID] else { return false }
if info.isConnected { return true }
guard meshAttached else { return false }
let retention: TimeInterval = info.isVerifiedNickname
? TransportConfig.bleReachabilityRetentionVerifiedSeconds
: TransportConfig.bleReachabilityRetentionUnverifiedSeconds
return now.timeIntervalSince(info.lastSeen) <= retention
}
func nickname(for peerID: PeerID, connectedOnly: Bool) -> String? {
guard let peer = peers[peerID] else { return nil }
if connectedOnly && !peer.isConnected { return nil }
return peer.nickname
}
func fingerprint(for peerID: PeerID) -> String? {
peers[peerID]?.noisePublicKey?.sha256Fingerprint()
}
func displayNicknames(selfNickname: String) -> [PeerID: String] {
let connected = peers.filter { $0.value.isConnected }
let tuples = connected.map { ($0.key, $0.value.nickname, true) }
return PeerDisplayNameResolver.resolve(tuples, selfNickname: selfNickname)
}
func transportSnapshots(selfNickname: String) -> [TransportPeerSnapshot] {
let snapshot = Array(peers.values)
let resolvedNames = PeerDisplayNameResolver.resolve(
snapshot.map { ($0.peerID, $0.nickname, $0.isConnected) },
selfNickname: selfNickname
)
return snapshot.map { info in
TransportPeerSnapshot(
peerID: info.peerID,
nickname: resolvedNames[info.peerID] ?? info.nickname,
isConnected: info.isConnected,
noisePublicKey: info.noisePublicKey,
lastSeen: info.lastSeen
)
}
}
func collisionResolvedNickname(for peerID: PeerID, selfNickname: String) -> String? {
guard let info = peers[peerID], info.isVerifiedNickname else { return nil }
let hasCollision = peers.values.contains {
$0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID
} || selfNickname == info.nickname
return hasCollision ? info.nickname + "#" + String(peerID.id.prefix(4)) : info.nickname
}
mutating func markDisconnected(_ peerID: PeerID) {
guard var info = peers[peerID] else { return }
info.isConnected = false
peers[peerID] = info
}
mutating func updateLastSeen(_ peerID: PeerID, at date: Date) {
guard var peer = peers[peerID] else { return }
peer.lastSeen = date
peers[peerID] = peer
}
mutating func upsertVerifiedAnnounce(
peerID: PeerID,
nickname: String,
noisePublicKey: Data,
signingPublicKey: Data?,
isConnected: Bool,
now: Date
) -> BLEPeerAnnounceUpdate {
let existing = peers[peerID]
let update = BLEPeerAnnounceUpdate(
isNewPeer: existing == nil,
wasDisconnected: existing?.isConnected == false,
previousNickname: existing?.nickname
)
peers[peerID] = BLEPeerInfo(
peerID: existing?.peerID ?? peerID,
nickname: nickname,
isConnected: isConnected,
noisePublicKey: noisePublicKey,
signingPublicKey: signingPublicKey,
isVerifiedNickname: true,
lastSeen: now
)
return update
}
mutating func reconcileConnectivity(
now: Date,
linkStates: [PeerID: BLEPeerLinkPresence]
) -> BLEPeerConnectivityChanges {
var changes = BLEPeerConnectivityChanges()
for (peerID, peer) in Array(peers) {
let age = now.timeIntervalSince(peer.lastSeen)
let retention: TimeInterval = peer.isVerifiedNickname
? TransportConfig.bleReachabilityRetentionVerifiedSeconds
: TransportConfig.bleReachabilityRetentionUnverifiedSeconds
if peer.isConnected && age > TransportConfig.blePeerInactivityTimeoutSeconds {
let state = linkStates[peerID] ?? BLEPeerLinkPresence(hasPeripheral: false, hasCentral: false)
if !state.hasPeripheral && !state.hasCentral {
var updated = peer
updated.isConnected = false
peers[peerID] = updated
changes.disconnectedPeerIDs.append(peerID)
}
}
if !peer.isConnected && age > retention {
peers.removeValue(forKey: peerID)
changes.removedPeers.append(BLERemovedPeer(peerID: peerID, nickname: peer.nickname))
}
}
return changes
}
}
@@ -0,0 +1,60 @@
import BitFoundation
import Foundation
enum BLEPeerSenderDisplayName {
static func resolveKnownPeer(
peerID: PeerID,
localPeerID: PeerID,
localNickname: String,
peers: [PeerID: BLEPeerInfo],
allowConnectedUnverified: Bool
) -> String? {
if peerID == localPeerID {
return localNickname
}
guard let info = peers[peerID] else { return nil }
if info.isVerifiedNickname {
return collisionResolvedName(
displayName: info.nickname,
collisionNickname: info.nickname,
peerID: peerID,
localNickname: localNickname,
peers: peers
)
}
if allowConnectedUnverified, info.isConnected {
let displayName = info.nickname.isEmpty ? anonymousNickname(for: peerID) : info.nickname
return collisionResolvedName(
displayName: displayName,
collisionNickname: info.nickname,
peerID: peerID,
localNickname: localNickname,
peers: peers
)
}
return nil
}
static func anonymousNickname(for peerID: PeerID) -> String {
"anon" + String(peerID.id.prefix(4))
}
private static func collisionResolvedName(
displayName: String,
collisionNickname: String,
peerID: PeerID,
localNickname: String,
peers: [PeerID: BLEPeerInfo]
) -> String {
let hasCollision = peers.values.contains {
$0.isConnected && $0.nickname == collisionNickname && $0.peerID != peerID
} || localNickname == collisionNickname
guard hasCollision else { return displayName }
return displayName + "#" + String(peerID.id.prefix(4))
}
}
@@ -0,0 +1,84 @@
import BitFoundation
import Foundation
struct BLEReceivedPacketContext: Equatable {
let senderID: PeerID
let messageID: String
let messageType: MessageType?
let shouldDeduplicate: Bool
let logsHandlingDetails: Bool
}
struct BLEReceivePipeline {
static func context(for packet: BitchatPacket, localPeerID: PeerID) -> BLEReceivedPacketContext {
let senderID = PeerID(hexData: packet.senderID)
let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)"
let messageType = MessageType(rawValue: packet.type)
let allowSelfSyncReplay = packet.ttl == 0 && senderID == localPeerID
let shouldDeduplicate = messageType != .fragment && !allowSelfSyncReplay
return BLEReceivedPacketContext(
senderID: senderID,
messageID: messageID,
messageType: messageType,
shouldDeduplicate: shouldDeduplicate,
logsHandlingDetails: messageType != .announce
)
}
static func shouldCancelScheduledRelayForDuplicate(connectedPeerCount: Int) -> Bool {
connectedPeerCount > 2
}
static func relayDecision(
for packet: BitchatPacket,
senderID: PeerID,
localPeerID: PeerID,
degree: Int,
highDegreeThreshold: Int
) -> RelayDecision {
RelayController.decide(
ttl: packet.ttl,
senderIsSelf: senderID == localPeerID,
recipientIsSelf: PeerID(hexData: packet.recipientID) == localPeerID,
isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,
isDirectedEncrypted: packet.type == MessageType.noiseEncrypted.rawValue && packet.recipientID != nil,
isFragment: packet.type == MessageType.fragment.rawValue,
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
isAnnounce: packet.type == MessageType.announce.rawValue,
degree: degree,
highDegreeThreshold: highDegreeThreshold
)
}
}
struct BLERecentTrafficTracker: Equatable {
private var packetTimestamps: [Date] = []
var count: Int {
packetTimestamps.count
}
mutating func removeAll() {
packetTimestamps.removeAll()
}
mutating func recordPacket(at now: Date) {
packetTimestamps.append(now)
prune(at: now)
}
func hasTraffic(within seconds: TimeInterval, now: Date) -> Bool {
let cutoff = now.addingTimeInterval(-seconds)
return packetTimestamps.contains { $0 >= cutoff }
}
private mutating func prune(at now: Date) {
let cutoff = now.addingTimeInterval(-TransportConfig.bleRecentPacketWindowSeconds)
if packetTimestamps.count > TransportConfig.bleRecentPacketWindowMaxCount {
packetTimestamps.removeFirst(packetTimestamps.count - TransportConfig.bleRecentPacketWindowMaxCount)
}
packetTimestamps.removeAll { $0 < cutoff }
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
import Foundation
enum BLESubscriptionAnnounceDecision: Equatable {
case allowed
case rateLimited(backoffSeconds: TimeInterval, attemptCount: Int, suppressAnnounce: Bool)
}
struct BLESubscriptionAnnounceLimiter {
private struct State {
var lastAnnounceTime: Date
var attemptCount: Int
var currentBackoffSeconds: TimeInterval
}
private var states: [String: State] = [:]
var trackedCentralCount: Int {
states.count
}
mutating func removeAll() {
states.removeAll()
}
mutating func decision(for centralID: String, now: Date) -> BLESubscriptionAnnounceDecision {
pruneStaleEntries(now: now)
guard let existing = states[centralID] else {
recordAllowedAttempt(for: centralID, now: now)
return .allowed
}
let timeSinceLastAnnounce = now.timeIntervalSince(existing.lastAnnounceTime)
guard timeSinceLastAnnounce < existing.currentBackoffSeconds else {
recordAllowedAttempt(for: centralID, now: now)
return .allowed
}
let newAttemptCount = existing.attemptCount + 1
let newBackoff = min(
existing.currentBackoffSeconds * TransportConfig.bleSubscriptionRateLimitBackoffFactor,
TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds
)
states[centralID] = State(
lastAnnounceTime: now,
attemptCount: newAttemptCount,
currentBackoffSeconds: newBackoff
)
return .rateLimited(
backoffSeconds: existing.currentBackoffSeconds,
attemptCount: existing.attemptCount,
suppressAnnounce: newAttemptCount >= TransportConfig.bleSubscriptionRateLimitMaxAttempts
)
}
private mutating func recordAllowedAttempt(for centralID: String, now: Date) {
states[centralID] = State(
lastAnnounceTime: now,
attemptCount: 1,
currentBackoffSeconds: TransportConfig.bleSubscriptionRateLimitMinSeconds
)
}
private mutating func pruneStaleEntries(now: Date) {
let windowSeconds = TransportConfig.bleSubscriptionRateLimitWindowSeconds
states = states.filter { _, state in
now.timeIntervalSince(state.lastAnnounceTime) < windowSeconds
}
}
}
@@ -910,7 +910,7 @@ struct ChatViewModelMediaTransferTests {
viewModel.selectedPrivateChatPeer = peerID
viewModel.sendImage(from: sourceURL)
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 1.0)
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 5.0)
#expect(didSend)
#expect(transport.sentPrivateFiles.first?.peerID == peerID)
#expect(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg")
@@ -0,0 +1,148 @@
import BitFoundation
import Foundation
import Testing
@testable import bitchat
@Suite("BLE ingress packet guard tests")
struct BLEIngressPacketGuardTests {
@Test("valid packets return the received and validation peer context")
func validPacketsReturnContext() throws {
let local = PeerID(str: "0011223344556677")
let bound = PeerID(str: "1122334455667788")
let sender = PeerID(str: "8899aabbccddeeff")
let packet = makePacket(sender: sender, timestamp: 1_000)
let context = try #require(success(BLEIngressPacketGuard.evaluate(
packet: packet,
claimedSenderID: sender,
boundPeerID: bound,
localPeerID: local,
directAnnounceTTL: 7,
nowMs: 1_000,
isValidSyncResponse: { _ in false }
)))
#expect(context.receivedFromPeerID == bound)
#expect(context.validationPeerID == sender)
}
@Test("self loopback and direct announce spoofing are rejected before timestamp checks")
func linkBindingRejectionsWinBeforeTimestampChecks() {
let local = PeerID(str: "0011223344556677")
let bound = PeerID(str: "1122334455667788")
let claimed = PeerID(str: "8899aabbccddeeff")
let selfPacket = makePacket(sender: local, timestamp: 0)
let spoofedAnnounce = makePacket(type: .announce, sender: claimed, timestamp: 0, ttl: 7)
let selfResult = BLEIngressPacketGuard.evaluate(
packet: selfPacket,
claimedSenderID: local,
boundPeerID: bound,
localPeerID: local,
directAnnounceTTL: 7,
nowMs: 1_000_000,
isValidSyncResponse: { _ in false }
)
let spoofResult = BLEIngressPacketGuard.evaluate(
packet: spoofedAnnounce,
claimedSenderID: claimed,
boundPeerID: bound,
localPeerID: local,
directAnnounceTTL: 7,
nowMs: 1_000_000,
isValidSyncResponse: { _ in false }
)
#expect(selfResult == .failure(.selfLoopback(packetType: MessageType.message.rawValue)))
#expect(spoofResult == .failure(.directSenderMismatch(boundPeerID: bound, claimedSenderID: claimed)))
}
@Test("timestamp skew outside the window is rejected")
func timestampSkewIsRejected() {
let local = PeerID(str: "0011223344556677")
let sender = PeerID(str: "1122334455667788")
let packet = makePacket(sender: sender, timestamp: 1_000)
let result = BLEIngressPacketGuard.evaluate(
packet: packet,
claimedSenderID: sender,
boundPeerID: nil,
localPeerID: local,
directAnnounceTTL: 7,
nowMs: 200_000,
maxTimestampSkewMs: 120_000,
isValidSyncResponse: { _ in false }
)
#expect(result == .failure(.timestampSkew(
peerID: sender,
skewMs: 199_000,
maxSkewMs: 120_000
)))
}
@Test("valid RSR packets use bound peer validation and bypass timestamp skew")
func validRSRUsesBoundPeerAndBypassesTimestamp() throws {
let local = PeerID(str: "0011223344556677")
let bound = PeerID(str: "1122334455667788")
let sender = PeerID(str: "8899aabbccddeeff")
var packet = makePacket(sender: sender, timestamp: 1)
packet.isRSR = true
let context = try #require(success(BLEIngressPacketGuard.evaluate(
packet: packet,
claimedSenderID: sender,
boundPeerID: bound,
localPeerID: local,
directAnnounceTTL: 7,
nowMs: 1_000_000,
isValidSyncResponse: { $0 == bound }
)))
#expect(context.receivedFromPeerID == bound)
#expect(context.validationPeerID == bound)
}
@Test("invalid RSR packets are rejected")
func invalidRSRIsRejected() {
let local = PeerID(str: "0011223344556677")
let bound = PeerID(str: "1122334455667788")
let sender = PeerID(str: "8899aabbccddeeff")
var packet = makePacket(sender: sender, timestamp: 1)
packet.isRSR = true
let result = BLEIngressPacketGuard.evaluate(
packet: packet,
claimedSenderID: sender,
boundPeerID: bound,
localPeerID: local,
directAnnounceTTL: 7,
nowMs: 1_000_000,
isValidSyncResponse: { _ in false }
)
#expect(result == .failure(.invalidRSR(peerID: bound)))
}
private func makePacket(
type: MessageType = .message,
sender: PeerID,
timestamp: UInt64,
ttl: UInt8 = 3
) -> BitchatPacket {
BitchatPacket(
type: type.rawValue,
senderID: Data(hexString: sender.id) ?? Data(),
recipientID: nil,
timestamp: timestamp,
payload: Data([0x01, 0x02, 0x03]),
signature: nil,
ttl: ttl
)
}
private func success(_ result: Result<BLEIngressPacketContext, BLEIngressPacketGuard.Rejection>) -> BLEIngressPacketContext? {
guard case .success(let context) = result else { return nil }
return context
}
}
@@ -0,0 +1,36 @@
import Foundation
import Testing
@testable import bitchat
@Suite("BLE log rate limiter tests")
struct BLELogRateLimiterTests {
@Test("repeated log keys are suppressed until the default interval elapses")
func suppressesRepeatedKeysUntilIntervalElapses() {
let limiter = BLELogRateLimiter(defaultMinimumInterval: 5)
let now = Date(timeIntervalSince1970: 100)
#expect(limiter.shouldLog(key: "ready:peer-a", now: now))
#expect(!limiter.shouldLog(key: "ready:peer-a", now: now.addingTimeInterval(4.9)))
#expect(limiter.shouldLog(key: "ready:peer-a", now: now.addingTimeInterval(5)))
}
@Test("different log keys are tracked independently")
func tracksKeysIndependently() {
let limiter = BLELogRateLimiter(defaultMinimumInterval: 5)
let now = Date(timeIntervalSince1970: 100)
#expect(limiter.shouldLog(key: "ready:peer-a", now: now))
#expect(limiter.shouldLog(key: "ready:peer-b", now: now))
#expect(!limiter.shouldLog(key: "ready:peer-a", now: now.addingTimeInterval(1)))
}
@Test("call sites can override the default interval")
func supportsPerCallIntervals() {
let limiter = BLELogRateLimiter(defaultMinimumInterval: 5)
let now = Date(timeIntervalSince1970: 100)
#expect(limiter.shouldLog(key: "self-loopback", now: now, minimumInterval: 30))
#expect(!limiter.shouldLog(key: "self-loopback", now: now.addingTimeInterval(29), minimumInterval: 30))
#expect(limiter.shouldLog(key: "self-loopback", now: now.addingTimeInterval(30), minimumInterval: 30))
}
}
@@ -0,0 +1,136 @@
import BitFoundation
import Foundation
import Testing
@testable import bitchat
@Suite("BLE outbound fragment planner tests")
struct BLEOutboundFragmentPlannerTests {
@Test("planner splits packets and preserves reassembled payload")
func plannerSplitsAndReassemblesPacket() throws {
let packet = makePacket(payload: makePayload(count: 384))
let request = BLEOutboundFragmentTransferRequest(
packet: packet,
pad: false,
maxChunk: 128,
directedPeer: nil,
transferId: nil
)
let plan = try #require(BLEOutboundFragmentPlanner.makePlan(
for: request,
defaultChunkSize: 256,
bleMaxMTU: 512,
fragmentID: Data(repeating: 0xA1, count: 8)
))
let headers = try plan.fragmentPackets.map { try #require(BLEFragmentHeader(packet: $0)) }
let reassembled = headers.reduce(into: Data()) { data, header in
data.append(header.fragmentData)
}
let decoded = try #require(BinaryProtocol.decode(reassembled))
#expect(plan.fragmentVersion == 1)
#expect(plan.chunkSize == 128)
#expect(plan.spacingMs == TransportConfig.bleFragmentSpacingMs)
#expect(headers.map(\.index) == Array(0..<headers.count))
#expect(decoded.type == packet.type)
#expect(decoded.payload == packet.payload)
#expect(plan.fragmentPackets.allSatisfy { $0.recipientID == nil })
}
@Test("directed fragments target the directed peer and use directed pacing")
func directedFragmentsUseDirectedRecipientAndSpacing() throws {
let directedPeer = PeerID(str: "8877665544332211")
let packet = makePacket(payload: makePayload(count: 256))
let request = BLEOutboundFragmentTransferRequest(
packet: packet,
pad: false,
maxChunk: 96,
directedPeer: directedPeer,
transferId: nil
)
let plan = try #require(BLEOutboundFragmentPlanner.makePlan(
for: request,
defaultChunkSize: 256,
bleMaxMTU: 512,
fragmentID: Data(repeating: 0xB2, count: 8)
))
#expect(plan.spacingMs == TransportConfig.bleFragmentSpacingDirectedMs)
#expect(plan.fragmentPackets.allSatisfy { $0.recipientID == Data(hexString: directedPeer.id) })
}
@Test("route-aware fragments use version two and route-sized chunking")
func routeAwareFragmentsUseVersionTwoAndRouteSizedChunking() throws {
let route = [
Data([0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17]),
Data([0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27])
]
let packet = makePacket(payload: makePayload(count: 192), route: route, isRSR: true)
let request = BLEOutboundFragmentTransferRequest(
packet: packet,
pad: false,
maxChunk: nil,
directedPeer: nil,
transferId: nil
)
let plan = try #require(BLEOutboundFragmentPlanner.makePlan(
for: request,
defaultChunkSize: 256,
bleMaxMTU: 128,
fragmentID: Data(repeating: 0xC3, count: 8)
))
let firstHeader = try #require(BLEFragmentHeader(packet: plan.fragmentPackets[0]))
#expect(plan.fragmentVersion == 2)
#expect(plan.chunkSize == 64)
#expect(firstHeader.fragmentData.count <= 64)
#expect(plan.fragmentPackets.allSatisfy { $0.route == route && $0.isRSR })
}
@Test("invalid fragment IDs do not produce a plan")
func invalidFragmentIDReturnsNil() {
let packet = makePacket(payload: makePayload(count: 128))
let request = BLEOutboundFragmentTransferRequest(
packet: packet,
pad: false,
maxChunk: nil,
directedPeer: nil,
transferId: nil
)
#expect(BLEOutboundFragmentPlanner.makePlan(
for: request,
defaultChunkSize: 256,
bleMaxMTU: 512,
fragmentID: Data(repeating: 0x00, count: 7)
) == nil)
}
private func makePacket(
payload: Data,
route: [Data]? = nil,
isRSR: Bool = false
) -> BitchatPacket {
BitchatPacket(
type: MessageType.message.rawValue,
senderID: Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77]),
recipientID: nil,
timestamp: 0x0102030405,
payload: payload,
signature: nil,
ttl: 3,
route: route,
isRSR: isRSR
)
}
private func makePayload(count: Int, seed: UInt64 = 0xABCD_1234) -> Data {
var state = seed
return Data((0..<count).map { _ in
state = state &* 6364136223846793005 &+ 1442695040888963407
return UInt8(truncatingIfNeeded: state >> 32)
})
}
}
@@ -0,0 +1,111 @@
import BitFoundation
import Foundation
import Testing
@testable import bitchat
@Suite("BLE peer registry tests")
struct BLEPeerRegistryTests {
@Test("upserted announces track new, reconnect, and rename transitions")
func upsertVerifiedAnnounceTracksTransitions() {
var registry = BLEPeerRegistry()
let peerID = PeerID(str: "1122334455667788")
let firstSeen = Date(timeIntervalSince1970: 100)
let first = registry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: "alice",
noisePublicKey: Data([1, 2, 3]),
signingPublicKey: Data([4, 5, 6]),
isConnected: true,
now: firstSeen
)
#expect(first.isNewPeer)
#expect(!first.wasDisconnected)
#expect(first.previousNickname == nil)
#expect(registry.connectedPeerIDs == [peerID])
#expect(registry.nickname(for: peerID, connectedOnly: true) == "alice")
registry.markDisconnected(peerID)
let reconnect = registry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: "alice-renamed",
noisePublicKey: Data([1, 2, 3]),
signingPublicKey: Data([4, 5, 6]),
isConnected: true,
now: firstSeen.addingTimeInterval(1)
)
#expect(!reconnect.isNewPeer)
#expect(reconnect.wasDisconnected)
#expect(reconnect.previousNickname == "alice")
#expect(registry.info(for: peerID)?.nickname == "alice-renamed")
}
@Test("reachability keeps recent verified offline peers only when mesh is attached")
func reachabilityRequiresMeshAttachmentForOfflinePeers() {
let offlinePeer = PeerID(str: "1122334455667788")
let connectedPeer = PeerID(str: "8877665544332211")
let now = Date()
var isolatedRegistry = BLEPeerRegistry()
isolatedRegistry.upsert(BLEPeerInfo(
peerID: offlinePeer,
nickname: "offline",
isConnected: false,
noisePublicKey: nil,
signingPublicKey: nil,
isVerifiedNickname: true,
lastSeen: now
))
#expect(!isolatedRegistry.isReachable(offlinePeer, now: now))
var attachedRegistry = isolatedRegistry
attachedRegistry.upsert(BLEPeerInfo(
peerID: connectedPeer,
nickname: "connected",
isConnected: true,
noisePublicKey: nil,
signingPublicKey: nil,
isVerifiedNickname: true,
lastSeen: now
))
#expect(attachedRegistry.isReachable(offlinePeer, now: now))
}
@Test("connectivity reconciliation disconnects inactive peers and prunes expired offline peers")
func reconcileConnectivityUpdatesAndPrunesPeerState() {
var registry = BLEPeerRegistry()
let inactiveConnectedPeer = PeerID(str: "1122334455667788")
let expiredOfflinePeer = PeerID(str: "8877665544332211")
let now = Date()
registry.upsert(BLEPeerInfo(
peerID: inactiveConnectedPeer,
nickname: "inactive",
isConnected: true,
noisePublicKey: nil,
signingPublicKey: nil,
isVerifiedNickname: true,
lastSeen: now.addingTimeInterval(-TransportConfig.blePeerInactivityTimeoutSeconds - 1)
))
registry.upsert(BLEPeerInfo(
peerID: expiredOfflinePeer,
nickname: "expired",
isConnected: false,
noisePublicKey: nil,
signingPublicKey: nil,
isVerifiedNickname: true,
lastSeen: .distantPast
))
let changes = registry.reconcileConnectivity(now: now, linkStates: [:])
#expect(changes.disconnectedPeerIDs == [inactiveConnectedPeer])
#expect(changes.removedPeers.map(\.peerID) == [expiredOfflinePeer])
#expect(registry.info(for: inactiveConnectedPeer)?.isConnected == false)
#expect(registry.info(for: expiredOfflinePeer) == nil)
}
}
@@ -0,0 +1,78 @@
import BitFoundation
import Foundation
import Testing
@testable import bitchat
@Suite("BLE peer sender display name tests")
struct BLEPeerSenderDisplayNameTests {
@Test("local peer resolves to local nickname")
func localPeerUsesLocalNickname() {
let local = PeerID(str: "1122334455667788")
#expect(BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: local,
localPeerID: local,
localNickname: "me",
peers: [:],
allowConnectedUnverified: false
) == "me")
}
@Test("verified nickname collisions add peer suffix")
func verifiedCollisionAddsSuffix() {
let local = PeerID(str: "1122334455667788")
let peer = PeerID(str: "8877665544332211")
let peers = [
peer: makeInfo(peerID: peer, nickname: "sam", isConnected: false, isVerifiedNickname: true)
]
#expect(BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: peer,
localPeerID: local,
localNickname: "sam",
peers: peers,
allowConnectedUnverified: false
) == "sam#8877")
}
@Test("connected unverified fallback is opt in")
func connectedUnverifiedFallbackIsOptIn() {
let local = PeerID(str: "1122334455667788")
let peer = PeerID(str: "8877665544332211")
let peers = [
peer: makeInfo(peerID: peer, nickname: "", isConnected: true, isVerifiedNickname: false)
]
#expect(BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: peer,
localPeerID: local,
localNickname: "me",
peers: peers,
allowConnectedUnverified: false
) == nil)
#expect(BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: peer,
localPeerID: local,
localNickname: "me",
peers: peers,
allowConnectedUnverified: true
) == "anon8877")
}
private func makeInfo(
peerID: PeerID,
nickname: String,
isConnected: Bool,
isVerifiedNickname: Bool
) -> BLEPeerInfo {
BLEPeerInfo(
peerID: peerID,
nickname: nickname,
isConnected: isConnected,
noisePublicKey: nil,
signingPublicKey: nil,
isVerifiedNickname: isVerifiedNickname,
lastSeen: Date(timeIntervalSince1970: 0)
)
}
}
@@ -0,0 +1,105 @@
import BitFoundation
import Foundation
import Testing
@testable import bitchat
@Suite("BLE receive pipeline tests")
struct BLEReceivePipelineTests {
@Test("context includes sender, type-scoped message ID, and logging policy")
func contextBuildsTypeScopedMessageID() {
let sender = PeerID(str: "1122334455667788")
let local = PeerID(str: "8877665544332211")
let packet = makePacket(type: .message, sender: sender, timestamp: 1234)
let context = BLEReceivePipeline.context(for: packet, localPeerID: local)
#expect(context.senderID == sender)
#expect(context.messageID == "\(sender)-1234-\(MessageType.message.rawValue)")
#expect(context.messageType == .message)
#expect(context.shouldDeduplicate)
#expect(context.logsHandlingDetails)
}
@Test("fragments and self sync replays bypass global deduplication")
func contextBypassesDeduplicationForFragmentsAndSelfSyncReplay() {
let local = PeerID(str: "1122334455667788")
let remote = PeerID(str: "8877665544332211")
let fragment = BLEReceivePipeline.context(
for: makePacket(type: .fragment, sender: remote),
localPeerID: local
)
let selfReplay = BLEReceivePipeline.context(
for: makePacket(type: .message, sender: local, ttl: 0),
localPeerID: local
)
#expect(!fragment.shouldDeduplicate)
#expect(!selfReplay.shouldDeduplicate)
}
@Test("dense duplicate traffic cancels pending relays but sparse traffic does not")
func duplicateRelayCancellationUsesGraphDensity() {
#expect(!BLEReceivePipeline.shouldCancelScheduledRelayForDuplicate(connectedPeerCount: 2))
#expect(BLEReceivePipeline.shouldCancelScheduledRelayForDuplicate(connectedPeerCount: 3))
}
@Test("relay decision maps packet context and suppresses local recipient traffic")
func relayDecisionSuppressesLocalRecipientTraffic() {
let sender = PeerID(str: "1122334455667788")
let local = PeerID(str: "8877665544332211")
let packet = makePacket(
type: .noiseEncrypted,
sender: sender,
recipient: local,
ttl: 7
)
let decision = BLEReceivePipeline.relayDecision(
for: packet,
senderID: sender,
localPeerID: local,
degree: 3,
highDegreeThreshold: TransportConfig.bleHighDegreeThreshold
)
#expect(!decision.shouldRelay)
}
@Test("recent traffic tracker prunes by count and time window")
func recentTrafficTrackerPrunesByCountAndWindow() {
var tracker = BLERecentTrafficTracker()
let now = Date(timeIntervalSince1970: 1_000)
tracker.recordPacket(at: now.addingTimeInterval(-TransportConfig.bleRecentPacketWindowSeconds - 1))
#expect(!tracker.hasTraffic(within: TransportConfig.bleRecentPacketWindowSeconds, now: now))
for index in 0...TransportConfig.bleRecentPacketWindowMaxCount {
tracker.recordPacket(at: now.addingTimeInterval(Double(index) * 0.001))
}
#expect(tracker.count == TransportConfig.bleRecentPacketWindowMaxCount)
#expect(tracker.hasTraffic(within: 1.0, now: now.addingTimeInterval(0.1)))
tracker.removeAll()
#expect(tracker.count == 0)
}
private func makePacket(
type: MessageType,
sender: PeerID,
recipient: PeerID? = nil,
timestamp: UInt64 = 1,
ttl: UInt8 = 7
) -> BitchatPacket {
BitchatPacket(
type: type.rawValue,
senderID: Data(hexString: sender.id) ?? Data(),
recipientID: recipient.flatMap { Data(hexString: $0.id) },
timestamp: timestamp,
payload: Data([0x01, 0x02]),
signature: nil,
ttl: ttl
)
}
}
@@ -0,0 +1,61 @@
import Foundation
import Testing
@testable import bitchat
@Suite("BLE subscription announce limiter tests")
struct BLESubscriptionAnnounceLimiterTests {
@Test("first subscription is allowed and repeated subscriptions are rate limited")
func repeatedSubscriptionsAreRateLimited() {
var limiter = BLESubscriptionAnnounceLimiter()
let centralID = "central-a"
let now = Date(timeIntervalSince1970: 100)
#expect(limiter.decision(for: centralID, now: now) == .allowed)
#expect(limiter.trackedCentralCount == 1)
let second = limiter.decision(for: centralID, now: now.addingTimeInterval(0.1))
#expect(second == .rateLimited(
backoffSeconds: TransportConfig.bleSubscriptionRateLimitMinSeconds,
attemptCount: 1,
suppressAnnounce: false
))
}
@Test("rapid subscription attempts eventually suppress announces")
func rapidAttemptsSuppressAnnouncesAtThreshold() {
var limiter = BLESubscriptionAnnounceLimiter()
let centralID = "central-a"
let now = Date(timeIntervalSince1970: 100)
#expect(limiter.decision(for: centralID, now: now) == .allowed)
var decision = BLESubscriptionAnnounceDecision.allowed
for attempt in 2...TransportConfig.bleSubscriptionRateLimitMaxAttempts {
decision = limiter.decision(
for: centralID,
now: now.addingTimeInterval(Double(attempt) * 0.01)
)
}
if case let .rateLimited(_, _, suppressAnnounce) = decision {
#expect(suppressAnnounce)
} else {
Issue.record("Expected rate-limited decision at suppression threshold")
}
}
@Test("stale limiter entries are pruned on the next decision")
func staleEntriesArePruned() {
var limiter = BLESubscriptionAnnounceLimiter()
let staleCentralID = "central-a"
let freshCentralID = "central-b"
let now = Date(timeIntervalSince1970: 100)
#expect(limiter.decision(for: staleCentralID, now: now) == .allowed)
#expect(limiter.trackedCentralCount == 1)
let afterWindow = now.addingTimeInterval(TransportConfig.bleSubscriptionRateLimitWindowSeconds + 1)
#expect(limiter.decision(for: freshCentralID, now: afterWindow) == .allowed)
#expect(limiter.trackedCentralCount == 1)
}
}