Originate v2 source routes and wire fragmentIdFilter targeted resync (#1378)

* Originate v2 source routes and wire fragmentIdFilter targeted resync

Part A — source-route origination policy:
- Gate route application (BLESourceRouteOriginationPolicy): only packets we
  author, directed at a single peer, with TTL headroom, whose recipient is
  not directly connected. Relays no longer attach routes to (and re-sign)
  packets they merely forward.
- Version-gate paths: MeshTopologyTracker records the highest protocol
  version observed per peer; BFS routes require every intermediate hop and
  the recipient to be v2-observed, capped at 4 intermediate hops.
- Degrade on failure: BLESourceRouteFailureCache marks a routed send that
  sees no inbound traffic from the recipient within 10s as failed and floods
  for 60s before retrying routes.

Part B — REQUEST_SYNC fragmentIdFilter (TLV 0x06):
- Requester: BLEFragmentAssemblyBuffer reports stalled broadcast
  reassemblies (no new fragment for 5s, retried at most every 10s); the
  maintenance pass sends a types=fragment REQUEST_SYNC naming the stalled
  8-byte fragment stream IDs to each connected peer.
- Responder: GossipSyncManager restricts the fragment diff to exactly the
  named streams, bypassing the since-cursor while the GCS filter still
  excludes pieces the requester holds; RSR/TTL-0/rate-limit semantics
  unchanged and REQUEST_SYNC stays link-local.
- Bounds: at most 60 IDs per request (60*17-1 = 1019 bytes <= the 1024-byte
  decoder cap); oversized 0x06 values are ignored, not fatal.

Docs: SOURCE_ROUTING.md gains the iOS origination policy (§8);
REQUEST_SYNC_MANAGER.md documents 0x05/0x06 as implemented.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix stall-clock refresh on duplicates and overflow suppression in fragment resync

Two fixes to stalledBroadcastFragmentIDs bookkeeping in
BLEFragmentAssemblyBuffer:

- Duplicate fragments no longer reset the stall clock. Fragment packets
  bypass the packet deduplicator, so relayed duplicates of an
  already-held index arriving every few seconds kept lastFragmentAt
  fresh and suppressed the targeted REQUEST_SYNC indefinitely. Now
  lastFragmentAt only updates when the index is new (actual progress).

- Only the streams that will actually be encoded on the wire are
  rate-limited. Previously every stalled candidate got
  lastResyncRequestAt set, but encodeFragmentIdFilter serializes at most
  RequestSyncPacket.maxFragmentIdFilterCount (60) IDs, so overflow
  streams were suppressed for retryAfter without ever being requested.
  Selection now caps at that shared constant, oldest stall first, so
  overflow stays eligible and rotates fairly on the next pass.

Tests: duplicates arriving periodically still trigger the stall report;
70 stalled streams yield the 60 oldest on the first pass and the
remaining 10 on the next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-07 14:42:53 +02:00
committed by GitHub
co-authored by jack Claude Fable 5
parent cee2bcd535
commit 70229f0be1
17 changed files with 1049 additions and 43 deletions
+94 -1
View File
@@ -507,6 +507,98 @@ 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 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 {
@@ -583,6 +675,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()
@@ -604,6 +697,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)
}
}