mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 04:45:20 +00:00
Merge remote-tracking branch 'origin/feat/source-route-send' into feat/integration-all
# Conflicts: # bitchat/Models/RequestSyncPacket.swift # bitchat/Services/BLE/BLEService.swift # bitchatTests/GossipSyncManagerTests.swift
This commit is contained in:
@@ -517,6 +517,73 @@ struct GossipSyncManagerTests {
|
||||
#expect(sentPackets[0].type == MessageType.fragment.rawValue)
|
||||
}
|
||||
|
||||
// MARK: - Fragment-ID filter (targeted resync)
|
||||
|
||||
private func makeFragmentPacket(sender: Data, fragmentID: Data, index: UInt16, timestamp: UInt64) -> BitchatPacket {
|
||||
// Fragment payload: 8-byte stream ID + index + total + original type.
|
||||
var payload = fragmentID
|
||||
payload.append(contentsOf: withUnsafeBytes(of: index.bigEndian) { Data($0) })
|
||||
payload.append(contentsOf: withUnsafeBytes(of: UInt16(4).bigEndian) { Data($0) })
|
||||
payload.append(MessageType.fileTransfer.rawValue)
|
||||
payload.append(Data([0xEE]))
|
||||
return BitchatPacket(
|
||||
type: MessageType.fragment.rawValue,
|
||||
senderID: sender,
|
||||
recipientID: nil,
|
||||
timestamp: timestamp,
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
}
|
||||
|
||||
@Test func handleRequestSyncHonorsFragmentIdFilter() async throws {
|
||||
var config = GossipSyncManager.Config()
|
||||
config.fragmentCapacity = 10
|
||||
config.messageSyncIntervalSeconds = 0
|
||||
config.fragmentSyncIntervalSeconds = 0
|
||||
config.fileTransferSyncIntervalSeconds = 0
|
||||
|
||||
let requestSyncManager = RequestSyncManager()
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
||||
let delegate = RecordingDelegate()
|
||||
manager.delegate = delegate
|
||||
|
||||
let sender = try #require(Data(hexString: "aabbccddeeff0011"))
|
||||
let wantedID = try #require(Data(hexString: "0102030405060708"))
|
||||
let otherID = try #require(Data(hexString: "1112131415161718"))
|
||||
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
|
||||
let wanted = makeFragmentPacket(sender: sender, fragmentID: wantedID, index: 1, timestamp: nowMs - 60_000)
|
||||
let other = makeFragmentPacket(sender: sender, fragmentID: otherID, index: 2, timestamp: nowMs)
|
||||
manager.onPublicPacketSeen(wanted)
|
||||
manager.onPublicPacketSeen(other)
|
||||
|
||||
// The since-cursor sits after both fragments; without the filter the
|
||||
// responder would send nothing for `wanted`. The filter both bypasses
|
||||
// the cursor and restricts the diff to exactly the named stream.
|
||||
let request = RequestSyncPacket(
|
||||
p: 7,
|
||||
m: 1,
|
||||
data: Data(),
|
||||
types: .fragment,
|
||||
sinceTimestamp: nowMs + 1,
|
||||
fragmentIdFilter: RequestSyncPacket.encodeFragmentIdFilter([wantedID])
|
||||
)
|
||||
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
|
||||
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
// Barrier: flush the sync queue so a late second packet would be visible.
|
||||
manager._performMaintenanceSynchronously(now: Date())
|
||||
let sentPackets = delegate.packets
|
||||
#expect(sentPackets.count == 1)
|
||||
let sent = try #require(sentPackets.first)
|
||||
#expect(sent.type == MessageType.fragment.rawValue)
|
||||
#expect(sent.payload.prefix(8) == wantedID)
|
||||
#expect(sent.ttl == 0)
|
||||
#expect(sent.isRSR)
|
||||
}
|
||||
|
||||
@Test func prekeyBundlesServeSyncAndSurviveStalePeerCleanup() async throws {
|
||||
var config = GossipSyncManager.Config()
|
||||
config.messageSyncIntervalSeconds = 0
|
||||
@@ -604,6 +671,31 @@ struct GossipSyncManagerTests {
|
||||
#expect(manager._hasPrekeyBundle(for: ownerPeer))
|
||||
}
|
||||
|
||||
@Test func requestMissingFragmentsSendsFilteredRequestToConnectedPeers() async throws {
|
||||
var config = GossipSyncManager.Config()
|
||||
config.messageSyncIntervalSeconds = 0
|
||||
config.fragmentSyncIntervalSeconds = 0
|
||||
config.fileTransferSyncIntervalSeconds = 0
|
||||
|
||||
let requestSyncManager = RequestSyncManager()
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
||||
let delegate = RecordingDelegate()
|
||||
delegate.connectedPeers = [PeerID(str: "FFFFFFFFFFFFFFFF")]
|
||||
manager.delegate = delegate
|
||||
|
||||
let stalledID = try #require(Data(hexString: "0102030405060708"))
|
||||
manager.requestMissingFragments(fragmentIDs: [stalledID])
|
||||
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
let sent = try #require(delegate.packets.first)
|
||||
#expect(sent.type == MessageType.requestSync.rawValue)
|
||||
#expect(sent.ttl == 0)
|
||||
let request = try #require(RequestSyncPacket.decode(from: sent.payload))
|
||||
#expect(request.types == .fragment)
|
||||
let ids = try #require(RequestSyncPacket.decodeFragmentIdFilter(request.fragmentIdFilter))
|
||||
#expect(ids == Set([stalledID]))
|
||||
}
|
||||
|
||||
// MARK: - Archive persistence
|
||||
|
||||
@Test func publicMessagesRestoreFromArchiveAcrossRestart() async throws {
|
||||
@@ -680,6 +772,7 @@ struct GossipSyncManagerTests {
|
||||
|
||||
private final class RecordingDelegate: GossipSyncManager.Delegate {
|
||||
var onSend: (() -> Void)?
|
||||
var connectedPeers: [PeerID] = []
|
||||
private(set) var lastPacket: BitchatPacket?
|
||||
private(set) var packets: [BitchatPacket] = []
|
||||
private let lock = NSLock()
|
||||
@@ -701,6 +794,6 @@ private final class RecordingDelegate: GossipSyncManager.Delegate {
|
||||
}
|
||||
|
||||
func getConnectedPeers() -> [PeerID] {
|
||||
return []
|
||||
return connectedPeers
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,140 @@ struct BLEFragmentAssemblyBufferTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func stalledBroadcastAssemblyReportsFragmentIDOnceUntilRetryLapses() throws {
|
||||
var buffer = BLEFragmentAssemblyBuffer()
|
||||
let fragmentID = Data((1...8).map { UInt8($0) })
|
||||
let packet = makePacket(payload: makePayload(count: 256))
|
||||
let fragments = try makeFragments(for: packet, chunkSize: 128, fragmentID: fragmentID)
|
||||
let first = try #require(BLEFragmentHeader(packet: fragments[0]))
|
||||
|
||||
let t0 = Date(timeIntervalSince1970: 100)
|
||||
_ = buffer.append(first, maxInFlightAssemblies: 8, now: t0)
|
||||
|
||||
// Not yet stalled.
|
||||
let early = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(4))
|
||||
#expect(early.isEmpty)
|
||||
|
||||
// Stalled: reported once, big-endian stream ID.
|
||||
let stalled = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(6))
|
||||
#expect(stalled == [fragmentID])
|
||||
|
||||
// Within the retry window: not re-reported.
|
||||
let repeated = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(8))
|
||||
#expect(repeated.isEmpty)
|
||||
|
||||
// After the retry window it is requested again.
|
||||
let retried = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(17))
|
||||
#expect(retried == [fragmentID])
|
||||
}
|
||||
|
||||
@Test
|
||||
func newFragmentResetsStallClockAndCompletionStopsRequests() throws {
|
||||
var buffer = BLEFragmentAssemblyBuffer()
|
||||
let fragmentID = Data((10...17).map { UInt8($0) })
|
||||
let packet = makePacket(payload: makePayload(count: 384))
|
||||
let fragments = try makeFragments(for: packet, chunkSize: 128, fragmentID: fragmentID)
|
||||
let headers = try fragments.map { try #require(BLEFragmentHeader(packet: $0)) }
|
||||
#expect(headers.count >= 3)
|
||||
|
||||
let t0 = Date(timeIntervalSince1970: 100)
|
||||
_ = buffer.append(headers[0], maxInFlightAssemblies: 8, now: t0)
|
||||
// A fragment arriving at t0+4 resets the stall clock.
|
||||
_ = buffer.append(headers[1], maxInFlightAssemblies: 8, now: t0.addingTimeInterval(4))
|
||||
let afterProgress = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(6))
|
||||
#expect(afterProgress.isEmpty)
|
||||
|
||||
// Completion removes the assembly entirely.
|
||||
var result: BLEFragmentAssemblyBuffer.AppendResult?
|
||||
for header in headers.dropFirst(2) {
|
||||
result = buffer.append(header, maxInFlightAssemblies: 8, now: t0.addingTimeInterval(5))
|
||||
}
|
||||
guard case .complete = result else {
|
||||
Issue.record("Expected assembly to complete")
|
||||
return
|
||||
}
|
||||
let afterCompletion = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(60))
|
||||
#expect(afterCompletion.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func duplicateFragmentsDoNotResetStallClock() throws {
|
||||
var buffer = BLEFragmentAssemblyBuffer()
|
||||
let fragmentID = Data((20...27).map { UInt8($0) })
|
||||
let packet = makePacket(payload: makePayload(count: 256))
|
||||
let fragments = try makeFragments(for: packet, chunkSize: 128, fragmentID: fragmentID)
|
||||
let first = try #require(BLEFragmentHeader(packet: fragments[0]))
|
||||
|
||||
let t0 = Date(timeIntervalSince1970: 100)
|
||||
_ = buffer.append(first, maxInFlightAssemblies: 8, now: t0)
|
||||
|
||||
// Relay duplicates of the same index arrive every few seconds; they
|
||||
// bring no new data, so they must not keep the stream "fresh".
|
||||
_ = buffer.append(first, maxInFlightAssemblies: 8, now: t0.addingTimeInterval(3))
|
||||
_ = buffer.append(first, maxInFlightAssemblies: 8, now: t0.addingTimeInterval(5))
|
||||
|
||||
let stalled = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(6))
|
||||
#expect(stalled == [fragmentID])
|
||||
}
|
||||
|
||||
@Test
|
||||
func overflowStalledStreamsRotateAcrossPasses() throws {
|
||||
var buffer = BLEFragmentAssemblyBuffer()
|
||||
let cap = RequestSyncPacket.maxFragmentIdFilterCount
|
||||
let streamCount = cap + 10
|
||||
let t0 = Date(timeIntervalSince1970: 100)
|
||||
|
||||
// Incomplete broadcast assemblies with staggered last-fragment times
|
||||
// (stream 0 is the oldest stall).
|
||||
var ids: [Data] = []
|
||||
for i in 0..<streamCount {
|
||||
let fragmentID = Data([0xAB, 0, 0, 0, 0, 0, UInt8(i >> 8), UInt8(i & 0xFF)])
|
||||
ids.append(fragmentID)
|
||||
let header = try #require(BLEFragmentHeader(packet: makeFragmentPacket(
|
||||
fragmentID: fragmentID,
|
||||
index: 0,
|
||||
total: 2,
|
||||
originalType: MessageType.message.rawValue,
|
||||
fragmentData: Data([0x01])
|
||||
)))
|
||||
_ = buffer.append(header, maxInFlightAssemblies: streamCount, now: t0.addingTimeInterval(Double(i)))
|
||||
}
|
||||
|
||||
// All streams are stalled; only the cap's worth (oldest first) is
|
||||
// requested and rate-limited, the overflow stays eligible.
|
||||
let firstPassAt = t0.addingTimeInterval(Double(streamCount) + 5)
|
||||
let firstPass = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 60, now: firstPassAt)
|
||||
#expect(firstPass == Array(ids.prefix(cap)))
|
||||
|
||||
// Next pass picks up exactly the overflow streams.
|
||||
let secondPass = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 60, now: firstPassAt.addingTimeInterval(1))
|
||||
#expect(secondPass == Array(ids.suffix(streamCount - cap)))
|
||||
|
||||
// Nothing left until a retry window lapses.
|
||||
let thirdPass = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 60, now: firstPassAt.addingTimeInterval(2))
|
||||
#expect(thirdPass.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func directedAssembliesAreNeverReportedAsStalled() throws {
|
||||
var buffer = BLEFragmentAssemblyBuffer()
|
||||
let fragment = makeFragmentPacket(
|
||||
fragmentID: Data(repeating: 0x0A, count: 8),
|
||||
index: 0,
|
||||
total: 2,
|
||||
originalType: MessageType.message.rawValue,
|
||||
fragmentData: Data([0x01]),
|
||||
recipientID: Data(hexString: "0102030405060708")
|
||||
)
|
||||
let header = try #require(BLEFragmentHeader(packet: fragment))
|
||||
|
||||
let t0 = Date(timeIntervalSince1970: 100)
|
||||
_ = buffer.append(header, maxInFlightAssemblies: 8, now: t0)
|
||||
let stalled = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(60))
|
||||
#expect(stalled.isEmpty)
|
||||
}
|
||||
|
||||
private func makePacket(payload: Data, timestamp: UInt64 = 0x0102030405) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//
|
||||
// BLESourceRouteFailureCacheTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
struct BLESourceRouteFailureCacheTests {
|
||||
private let recipient = PeerID(str: "0102030405060708")
|
||||
private let config = BLESourceRouteFailureCache.Config(
|
||||
confirmationWindowSeconds: 10,
|
||||
suppressionSeconds: 60
|
||||
)
|
||||
|
||||
private func attempts(_ cache: inout BLESourceRouteFailureCache, at date: Date) -> Bool {
|
||||
cache.shouldAttemptRoute(to: recipient, now: date)
|
||||
}
|
||||
|
||||
@Test func allowsRoutingByDefault() {
|
||||
var cache = BLESourceRouteFailureCache(config: config)
|
||||
#expect(attempts(&cache, at: Date()))
|
||||
}
|
||||
|
||||
@Test func unconfirmedRoutedSendSuppressesRouting() {
|
||||
var cache = BLESourceRouteFailureCache(config: config)
|
||||
let t0 = Date()
|
||||
|
||||
cache.noteRoutedSend(to: recipient, now: t0)
|
||||
// Inside the confirmation window: keep routing.
|
||||
#expect(attempts(&cache, at: t0.addingTimeInterval(5)))
|
||||
// Past the window with no inbound traffic: route failed, flood.
|
||||
#expect(!attempts(&cache, at: t0.addingTimeInterval(11)))
|
||||
// Still suppressed for the suppression TTL.
|
||||
#expect(!attempts(&cache, at: t0.addingTimeInterval(40)))
|
||||
// Suppression lapses: routing may be attempted again.
|
||||
#expect(attempts(&cache, at: t0.addingTimeInterval(11 + 61)))
|
||||
}
|
||||
|
||||
@Test func inboundActivityConfirmsPendingSend() {
|
||||
var cache = BLESourceRouteFailureCache(config: config)
|
||||
let t0 = Date()
|
||||
|
||||
cache.noteRoutedSend(to: recipient, now: t0)
|
||||
cache.noteInboundActivity(from: recipient)
|
||||
// Confirmed: no suppression even long after the window.
|
||||
#expect(attempts(&cache, at: t0.addingTimeInterval(30)))
|
||||
}
|
||||
|
||||
@Test func inboundActivityDoesNotLiftActiveSuppression() {
|
||||
var cache = BLESourceRouteFailureCache(config: config)
|
||||
let t0 = Date()
|
||||
|
||||
cache.noteRoutedSend(to: recipient, now: t0)
|
||||
// Trip the failure → suppression starts at t0+15.
|
||||
#expect(!attempts(&cache, at: t0.addingTimeInterval(15)))
|
||||
// Inbound traffic may have arrived via flood; suppression holds.
|
||||
cache.noteInboundActivity(from: recipient)
|
||||
#expect(!attempts(&cache, at: t0.addingTimeInterval(20)))
|
||||
#expect(attempts(&cache, at: t0.addingTimeInterval(15 + 61)))
|
||||
}
|
||||
|
||||
@Test func backToBackSendsShareOneDeadline() {
|
||||
var cache = BLESourceRouteFailureCache(config: config)
|
||||
let t0 = Date()
|
||||
|
||||
cache.noteRoutedSend(to: recipient, now: t0)
|
||||
cache.noteRoutedSend(to: recipient, now: t0.addingTimeInterval(8))
|
||||
// Deadline runs from the first unconfirmed send.
|
||||
#expect(!attempts(&cache, at: t0.addingTimeInterval(11)))
|
||||
}
|
||||
|
||||
@Test func pruneDropsExpiredEntries() {
|
||||
var cache = BLESourceRouteFailureCache(config: config)
|
||||
let t0 = Date()
|
||||
|
||||
cache.noteRoutedSend(to: recipient, now: t0)
|
||||
// Past confirmation + suppression: the entry can no longer matter.
|
||||
cache.prune(now: t0.addingTimeInterval(75))
|
||||
#expect(attempts(&cache, at: t0.addingTimeInterval(76)))
|
||||
}
|
||||
|
||||
@Test func pruneKeepsEntriesThatStillMatter() {
|
||||
var cache = BLESourceRouteFailureCache(config: config)
|
||||
let t0 = Date()
|
||||
|
||||
cache.noteRoutedSend(to: recipient, now: t0)
|
||||
cache.prune(now: t0.addingTimeInterval(30))
|
||||
// The unconverted pending entry survives pruning and still converts
|
||||
// into a suppression on the next routing decision.
|
||||
#expect(!attempts(&cache, at: t0.addingTimeInterval(31)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//
|
||||
// BLESourceRouteOriginationPolicyTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
struct BLESourceRouteOriginationPolicyTests {
|
||||
private let localPeerIDData = Data(hexString: "0102030405060708")!
|
||||
private let recipient = PeerID(str: "1112131415161718")
|
||||
private let hop = Data(hexString: "2122232425262728")!
|
||||
|
||||
private func makePacket(
|
||||
senderID: Data? = nil,
|
||||
recipientID: Data? = Data(hexString: "1112131415161718"),
|
||||
ttl: UInt8 = 7
|
||||
) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: senderID ?? localPeerIDData,
|
||||
recipientID: recipientID,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: Data([0x01]),
|
||||
signature: nil,
|
||||
ttl: ttl
|
||||
)
|
||||
}
|
||||
|
||||
private func route(
|
||||
packet: BitchatPacket,
|
||||
isRecipientConnected: Bool = false,
|
||||
shouldAttemptRoute: Bool = true,
|
||||
computedRoute: [Data]? = nil
|
||||
) -> [Data]? {
|
||||
BLESourceRouteOriginationPolicy.route(
|
||||
for: packet,
|
||||
to: recipient,
|
||||
localPeerIDData: localPeerIDData,
|
||||
isRecipientConnected: { _ in isRecipientConnected },
|
||||
shouldAttemptRoute: { _ in shouldAttemptRoute },
|
||||
computeRoute: { _ in computedRoute ?? [self.hop] }
|
||||
)
|
||||
}
|
||||
|
||||
@Test func routesWhenAllGatesPass() {
|
||||
#expect(route(packet: makePacket()) == [hop])
|
||||
}
|
||||
|
||||
@Test func relayedPacketNeverGetsRoute() {
|
||||
let relayed = makePacket(senderID: Data(hexString: "aabbccddeeff0011"))
|
||||
#expect(route(packet: relayed) == nil)
|
||||
}
|
||||
|
||||
@Test func broadcastRecipientNeverGetsRoute() {
|
||||
let broadcast = makePacket(recipientID: Data(repeating: 0xFF, count: 8))
|
||||
#expect(route(packet: broadcast) == nil)
|
||||
let noRecipient = makePacket(recipientID: nil)
|
||||
#expect(route(packet: noRecipient) == nil)
|
||||
}
|
||||
|
||||
@Test func linkLocalTTLNeverGetsRoute() {
|
||||
// TTL 0/1 packets (e.g. REQUEST_SYNC) cannot traverse hops.
|
||||
#expect(route(packet: makePacket(ttl: 0)) == nil)
|
||||
#expect(route(packet: makePacket(ttl: 1)) == nil)
|
||||
}
|
||||
|
||||
@Test func directlyConnectedRecipientNeverGetsRoute() {
|
||||
#expect(route(packet: makePacket(), isRecipientConnected: true) == nil)
|
||||
}
|
||||
|
||||
@Test func suppressedRecipientFallsBackToFlood() {
|
||||
#expect(route(packet: makePacket(), shouldAttemptRoute: false) == nil)
|
||||
}
|
||||
|
||||
@Test func missingOrEmptyRouteFallsBackToFlood() {
|
||||
var sawComputeRoute = false
|
||||
let result = BLESourceRouteOriginationPolicy.route(
|
||||
for: makePacket(),
|
||||
to: recipient,
|
||||
localPeerIDData: localPeerIDData,
|
||||
isRecipientConnected: { _ in false },
|
||||
shouldAttemptRoute: { _ in true },
|
||||
computeRoute: { _ in
|
||||
sawComputeRoute = true
|
||||
return nil
|
||||
}
|
||||
)
|
||||
#expect(result == nil)
|
||||
#expect(sawComputeRoute)
|
||||
#expect(route(packet: makePacket(), computedRoute: []) == nil)
|
||||
}
|
||||
}
|
||||
@@ -94,10 +94,132 @@ struct MeshTopologyTrackerTests {
|
||||
|
||||
tracker.updateNeighbors(for: a, neighbors: [b])
|
||||
tracker.updateNeighbors(for: b, neighbors: [a])
|
||||
|
||||
|
||||
// When start == end, route should be empty (no intermediate hops needed)
|
||||
let route = try #require(tracker.computeRoute(from: a, to: a))
|
||||
#expect(route == [])
|
||||
}
|
||||
|
||||
@Test func noPathReturnsNil() throws {
|
||||
let tracker = MeshTopologyTracker()
|
||||
let a = try hex("0101010101010101")
|
||||
let b = try hex("0202020202020202")
|
||||
let c = try hex("0303030303030303")
|
||||
let d = try hex("0404040404040404")
|
||||
|
||||
// Two disconnected islands: A-B and C-D
|
||||
tracker.updateNeighbors(for: a, neighbors: [b])
|
||||
tracker.updateNeighbors(for: b, neighbors: [a])
|
||||
tracker.updateNeighbors(for: c, neighbors: [d])
|
||||
tracker.updateNeighbors(for: d, neighbors: [c])
|
||||
|
||||
#expect(tracker.computeRoute(from: a, to: d) == nil)
|
||||
}
|
||||
|
||||
/// Build a confirmed line topology n0 - n1 - ... - n(count-1).
|
||||
private func makeLine(_ tracker: MeshTopologyTracker, count: Int) throws -> [Data] {
|
||||
let nodes = try (0..<count).map { try hex(String(format: "%016x", $0 + 1)) }
|
||||
for i in 0..<count {
|
||||
var neighbors: [Data] = []
|
||||
if i > 0 { neighbors.append(nodes[i - 1]) }
|
||||
if i < count - 1 { neighbors.append(nodes[i + 1]) }
|
||||
tracker.updateNeighbors(for: nodes[i], neighbors: neighbors)
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
@Test func maxHopsCapsIntermediateHopCount() throws {
|
||||
let tracker = MeshTopologyTracker()
|
||||
// 7 nodes: source + 5 intermediates + target
|
||||
let nodes = try makeLine(tracker, count: 7)
|
||||
|
||||
// 5 intermediates exceed a 4-hop cap
|
||||
#expect(tracker.computeRoute(from: nodes[0], to: nodes[6], maxHops: 4) == nil)
|
||||
// 4 intermediates fit exactly
|
||||
let route = try #require(tracker.computeRoute(from: nodes[0], to: nodes[5], maxHops: 4))
|
||||
#expect(route == Array(nodes[1...4]))
|
||||
}
|
||||
|
||||
@Test func staleNeighborBlocksRoute() throws {
|
||||
let tracker = MeshTopologyTracker()
|
||||
let a = try hex("0101010101010101")
|
||||
let b = try hex("0202020202020202")
|
||||
let c = try hex("0303030303030303")
|
||||
|
||||
let staleDate = Date().addingTimeInterval(-120) // past 60s freshness
|
||||
tracker.updateNeighbors(for: a, neighbors: [b])
|
||||
tracker.updateNeighbors(for: b, neighbors: [a, c], at: staleDate)
|
||||
tracker.updateNeighbors(for: c, neighbors: [b])
|
||||
|
||||
#expect(tracker.computeRoute(from: a, to: c) == nil)
|
||||
|
||||
// Refreshing B restores the route.
|
||||
tracker.updateNeighbors(for: b, neighbors: [a, c])
|
||||
let route = try #require(tracker.computeRoute(from: a, to: c))
|
||||
#expect(route == [b])
|
||||
}
|
||||
|
||||
@Test func versionGateBlocksV1AndUnknownHops() throws {
|
||||
let tracker = MeshTopologyTracker()
|
||||
let a = try hex("0101010101010101")
|
||||
let b = try hex("0202020202020202")
|
||||
let c = try hex("0303030303030303")
|
||||
|
||||
tracker.updateNeighbors(for: a, neighbors: [b])
|
||||
tracker.updateNeighbors(for: b, neighbors: [a, c])
|
||||
tracker.updateNeighbors(for: c, neighbors: [b])
|
||||
|
||||
// Without the gate the route exists.
|
||||
#expect(tracker.computeRoute(from: a, to: c) == [b])
|
||||
// Version-unknown hops are assumed v1-only and block gated routes.
|
||||
#expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == nil)
|
||||
|
||||
// A v1 observation does not unlock the gate.
|
||||
tracker.recordObservedVersion(1, for: b)
|
||||
tracker.recordObservedVersion(2, for: c)
|
||||
#expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == nil)
|
||||
|
||||
// Once the hop is observed speaking v2 the route opens.
|
||||
tracker.recordObservedVersion(2, for: b)
|
||||
let route = try #require(tracker.computeRoute(from: a, to: c, requiringVersion: 2))
|
||||
#expect(route == [b])
|
||||
}
|
||||
|
||||
@Test func versionGateRequiresV2Target() throws {
|
||||
let tracker = MeshTopologyTracker()
|
||||
let a = try hex("0101010101010101")
|
||||
let b = try hex("0202020202020202")
|
||||
let c = try hex("0303030303030303")
|
||||
|
||||
tracker.updateNeighbors(for: a, neighbors: [b])
|
||||
tracker.updateNeighbors(for: b, neighbors: [a, c])
|
||||
tracker.updateNeighbors(for: c, neighbors: [b])
|
||||
tracker.recordObservedVersion(2, for: b)
|
||||
|
||||
// The recipient must decode the v2 frame too.
|
||||
#expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == nil)
|
||||
|
||||
tracker.recordObservedVersion(2, for: c)
|
||||
#expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == [b])
|
||||
}
|
||||
|
||||
@Test func pruneDropsStaleObservedVersions() throws {
|
||||
let tracker = MeshTopologyTracker()
|
||||
let a = try hex("0101010101010101")
|
||||
let b = try hex("0202020202020202")
|
||||
let c = try hex("0303030303030303")
|
||||
|
||||
tracker.updateNeighbors(for: a, neighbors: [b])
|
||||
tracker.updateNeighbors(for: b, neighbors: [a, c])
|
||||
tracker.updateNeighbors(for: c, neighbors: [b])
|
||||
let old = Date().addingTimeInterval(-120)
|
||||
tracker.recordObservedVersion(2, for: b, at: old)
|
||||
tracker.recordObservedVersion(2, for: c, at: old)
|
||||
|
||||
#expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == [b])
|
||||
tracker.prune(olderThan: 60)
|
||||
// Claims are fresh but the version observations aged out.
|
||||
#expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == nil)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//
|
||||
// RequestSyncPacketFragmentFilterTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
struct RequestSyncPacketFragmentFilterTests {
|
||||
|
||||
@Test func fragmentIdFilterRoundTripsThroughWireEncoding() throws {
|
||||
let id1 = try #require(Data(hexString: "00112233445566aa"))
|
||||
let id2 = try #require(Data(hexString: "ffeeddccbbaa9988"))
|
||||
let filter = try #require(RequestSyncPacket.encodeFragmentIdFilter([id1, id2]))
|
||||
|
||||
let packet = RequestSyncPacket(p: 7, m: 128, data: Data([0x01]), types: .fragment, fragmentIdFilter: filter)
|
||||
let decoded = try #require(RequestSyncPacket.decode(from: packet.encode()))
|
||||
|
||||
#expect(decoded.fragmentIdFilter == filter)
|
||||
let ids = try #require(RequestSyncPacket.decodeFragmentIdFilter(decoded.fragmentIdFilter))
|
||||
#expect(ids == Set([id1, id2]))
|
||||
}
|
||||
|
||||
@Test func encodeCapsFilterAtMaxCountWithinDecoderBudget() throws {
|
||||
let ids = (0..<100).map { i -> Data in
|
||||
var id = Data(repeating: 0, count: 8)
|
||||
id[7] = UInt8(i)
|
||||
return id
|
||||
}
|
||||
let filter = try #require(RequestSyncPacket.encodeFragmentIdFilter(ids))
|
||||
|
||||
let tokens = filter.split(separator: ",")
|
||||
#expect(tokens.count == RequestSyncPacket.maxFragmentIdFilterCount)
|
||||
// 60 IDs * 17 bytes ("<16 hex>,") - 1 = 1019 ≤ the 1024-byte cap.
|
||||
#expect(filter.utf8.count == 1019)
|
||||
#expect(filter.utf8.count <= 1024)
|
||||
}
|
||||
|
||||
@Test func encodeDropsMalformedIDs() throws {
|
||||
let good = try #require(Data(hexString: "0011223344556677"))
|
||||
let short = Data([0x01, 0x02])
|
||||
let filter = try #require(RequestSyncPacket.encodeFragmentIdFilter([short, good]))
|
||||
#expect(filter == good.hexEncodedString())
|
||||
#expect(RequestSyncPacket.encodeFragmentIdFilter([short]) == nil)
|
||||
#expect(RequestSyncPacket.encodeFragmentIdFilter([]) == nil)
|
||||
}
|
||||
|
||||
@Test func decodeIgnoresMalformedTokens() throws {
|
||||
let good = try #require(Data(hexString: "0011223344556677"))
|
||||
let ids = try #require(
|
||||
RequestSyncPacket.decodeFragmentIdFilter("zzzz,0011,0011223344556677,")
|
||||
)
|
||||
#expect(ids == Set([good]))
|
||||
#expect(RequestSyncPacket.decodeFragmentIdFilter(nil) == nil)
|
||||
#expect(RequestSyncPacket.decodeFragmentIdFilter("not-hex") == nil)
|
||||
}
|
||||
|
||||
@Test func decoderIgnoresOversizedFilterValue() throws {
|
||||
// Hand-roll a payload whose 0x06 TLV exceeds the acceptance cap; the
|
||||
// request must still decode, with the filter dropped.
|
||||
var payload = RequestSyncPacket(p: 7, m: 128, data: Data([0x01])).encode()
|
||||
let oversized = Data(repeating: UInt8(ascii: "a"), count: 1025)
|
||||
payload.append(0x06)
|
||||
payload.append(UInt8((oversized.count >> 8) & 0xFF))
|
||||
payload.append(UInt8(oversized.count & 0xFF))
|
||||
payload.append(oversized)
|
||||
|
||||
let decoded = try #require(RequestSyncPacket.decode(from: payload))
|
||||
#expect(decoded.fragmentIdFilter == nil)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user