mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
[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:
@@ -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) })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user