diff --git a/bitchat/Models/RequestSyncPacket.swift b/bitchat/Models/RequestSyncPacket.swift index 0db8e7aa..b34dd925 100644 --- a/bitchat/Models/RequestSyncPacket.swift +++ b/bitchat/Models/RequestSyncPacket.swift @@ -4,6 +4,15 @@ import Foundation // - 0x01: P (uint8) — Golomb-Rice parameter // - 0x02: M (uint32, big-endian) — hash range (N * 2^P) // - 0x03: data (opaque) — GR bitstream bytes (MSB-first) +// - 0x04: types (bitfield) — SyncTypeFlags of covered message types +// - 0x05: sinceTimestamp (uint64, big-endian) — oldest ts the filter covers +// - 0x06: fragmentIdFilter (utf8) — reserved +// +// TODO(v2): fragmentIdFilter (0x06) is parsed and re-serialized but never +// populated or honored — it's the reserved surface for incremental fragment +// sync (request the missing fragments of one file by ID instead of diffing the +// whole fragment set). Either wire it into buildGcsPayload/_handleRequestSync +// or drop the field; don't leave it as silent dead protocol surface. struct RequestSyncPacket { let p: Int let m: UInt32 diff --git a/bitchat/Sync/GossipSyncManager.swift b/bitchat/Sync/GossipSyncManager.swift index c966a7ca..f848be7b 100644 --- a/bitchat/Sync/GossipSyncManager.swift +++ b/bitchat/Sync/GossipSyncManager.swift @@ -90,7 +90,7 @@ final class GossipSyncManager { private var messages = PacketStore() private var fragments = PacketStore() private var fileTransfers = PacketStore() - private var latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:] + private var latestAnnouncementByPeer: [PeerID: BitchatPacket] = [:] private var archiveDirty = false // Timer @@ -206,9 +206,8 @@ final class GossipSyncManager { removeState(for: sender) return } - let idHex = PacketIdUtil.computeId(packet).hexEncodedString() let sender = PeerID(hexData: packet.senderID) - latestAnnouncementByPeer[sender] = (id: idHex, packet: packet) + latestAnnouncementByPeer[sender] = packet case .message: guard isBroadcastRecipient else { return } guard isPacketFresh(packet) else { return } @@ -312,10 +311,9 @@ final class GossipSyncManager { // keys needed to verify everything else, and there is at most one per // peer, so the resend cost is negligible. if requestedTypes.contains(.announce) { - for (_, pair) in latestAnnouncementByPeer { - let (idHex, pkt) = pair + for (_, pkt) in latestAnnouncementByPeer { guard isPacketFresh(pkt) else { continue } - let idBytes = Data(hexString: idHex) ?? Data() + let idBytes = PacketIdUtil.computeId(pkt) if !mightContain(idBytes) { var toSend = pkt toSend.ttl = 0 @@ -372,8 +370,8 @@ final class GossipSyncManager { private func buildGcsPayload(for types: SyncTypeFlags) -> Data { var candidates: [BitchatPacket] = [] if types.contains(.announce) { - for (_, pair) in latestAnnouncementByPeer where isPacketFresh(pair.packet) { - candidates.append(pair.packet) + for (_, pkt) in latestAnnouncementByPeer where isPacketFresh(pkt) { + candidates.append(pkt) } } if types.contains(.message) { @@ -431,8 +429,8 @@ final class GossipSyncManager { // Periodic cleanup of expired messages and announcements private func cleanupExpiredMessages() { // Remove expired announcements - latestAnnouncementByPeer = latestAnnouncementByPeer.filter { _, pair in - isPacketFresh(pair.packet) + latestAnnouncementByPeer = latestAnnouncementByPeer.filter { _, pkt in + isPacketFresh(pkt) } let messageCountBefore = messages.packets.count @@ -512,8 +510,8 @@ final class GossipSyncManager { let nowMs = UInt64(now.timeIntervalSince1970 * 1000) guard nowMs >= timeoutMs else { return } let cutoff = nowMs - timeoutMs - let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pair in - pair.packet.timestamp < cutoff ? peerID : nil + let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pkt in + pkt.timestamp < cutoff ? peerID : nil } guard !stalePeerIDs.isEmpty else { return } for peerKey in stalePeerIDs { diff --git a/bitchat/Sync/SyncTypeFlags.swift b/bitchat/Sync/SyncTypeFlags.swift index 430485a0..fe796809 100644 --- a/bitchat/Sync/SyncTypeFlags.swift +++ b/bitchat/Sync/SyncTypeFlags.swift @@ -7,9 +7,25 @@ struct SyncTypeFlags: OptionSet { let rawValue: UInt64 init(rawValue: UInt64) { - self.rawValue = rawValue & 0x00FF_FFFF_FFFF_FFFF // Trim to max 8 bytes + // Drop any bit that doesn't map to a known message type. Wire data can + // carry up to 8 bytes of flags; without this mask, bits with no type + // (a truncated/garbled field, or a type a newer peer added) would live + // in the set as phantom membership that no `contains` check matches and + // `toData` re-serializes — a meaningless "accepted but does nothing" + // state. Masking here keeps every instance normalized at the source. + self.rawValue = rawValue & SyncTypeFlags.knownTypeMask } + /// Union of every bit that maps to a message type. Derived from the + /// bit↔type table so it tracks automatically when a type is added. + private static let knownTypeMask: UInt64 = { + var mask: UInt64 = 0 + for bit in 0..<64 where SyncTypeFlags.type(forBit: bit) != nil { + mask |= (1 << UInt64(bit)) + } + return mask + }() + private static func bitIndex(for type: MessageType) -> Int? { switch type { case .announce: return 0 diff --git a/bitchatTests/GossipSyncManagerTests.swift b/bitchatTests/GossipSyncManagerTests.swift index c44de8f6..0e7b35eb 100644 --- a/bitchatTests/GossipSyncManagerTests.swift +++ b/bitchatTests/GossipSyncManagerTests.swift @@ -357,6 +357,44 @@ struct GossipSyncManagerTests { #expect(sentPackets.allSatisfy { $0.isRSR }) } + @Test func handleRequestSyncSkipsAnnounceAlreadyInFilter() 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() + manager.delegate = delegate + + let sender = try #require(Data(hexString: "aabbccddeeff0011")) + let announcePacket = BitchatPacket( + type: MessageType.announce.rawValue, + senderID: sender, + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: Data(), + signature: nil, + ttl: 1 + ) + manager.onPublicPacketSeen(announcePacket) + + // A filter that already contains the announce's canonical ID must + // suppress the response — this only holds if the responder recomputes + // the ID the same way the filter was built (the dual-path bug would + // diff a stored hex string instead). + let announceID = PacketIdUtil.computeId(announcePacket) + let params = GCSFilter.buildFilter(ids: [announceID], maxBytes: 256, targetFpr: 0.01) + let request = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: .announce) + + let peer = PeerID(str: "FFFFFFFFFFFFFFFF") + manager.handleRequestSync(from: peer, request: request) + // Barrier: the async handler is enqueued, so this sync flush runs after it. + manager._performMaintenanceSynchronously(now: Date()) + #expect(delegate.packets.isEmpty) + } + @Test func handleRequestSyncIsRateLimitedPerPeer() async throws { var config = GossipSyncManager.Config() config.seenCapacity = 5 diff --git a/bitchatTests/Sync/SyncTypeFlagsTests.swift b/bitchatTests/Sync/SyncTypeFlagsTests.swift new file mode 100644 index 00000000..a3ab3cc4 --- /dev/null +++ b/bitchatTests/Sync/SyncTypeFlagsTests.swift @@ -0,0 +1,43 @@ +import Foundation +import Testing +import BitFoundation +@testable import bitchat + +struct SyncTypeFlagsTests { + + @Test func knownTypesRoundTripThroughData() throws { + let flags: SyncTypeFlags = [.announce, .message, .fragment, .fileTransfer] + let data = try #require(flags.toData()) + let decoded = try #require(SyncTypeFlags.decode(data)) + #expect(decoded == flags) + } + + @Test func decodeDropsPhantomBits() { + // Bits 8+ map to no message type. They must not survive decode as + // phantom membership. + let phantom = Data([0x00, 0xFF]) // bits 8..15 set, no known type + let decoded = SyncTypeFlags.decode(phantom) + #expect(decoded?.rawValue == 0) + #expect(decoded?.toMessageTypes().isEmpty == true) + } + + @Test func phantomBitsAreStrippedButKnownBitsSurvive() { + // Low byte = announce(0) + message(1); high byte = phantom. + let mixed = Data([0b0000_0011, 0xFF]) + let decoded = SyncTypeFlags.decode(mixed) + #expect(decoded?.contains(.announce) == true) + #expect(decoded?.contains(.message) == true) + // Only the two known bits remain; phantom high byte is gone. + #expect(decoded?.rawValue == 0b0000_0011) + } + + @Test func rawValueInitNormalizesPhantomBits() { + let flags = SyncTypeFlags(rawValue: 0xFFFF_FFFF_FFFF_FFFF) + // Every known type bit is set; nothing above them survives, so the + // field serializes to a single byte. + #expect(flags.contains(.announce)) + #expect(flags.contains(.fileTransfer)) + let data = flags.toData() + #expect(data?.count == 1) + } +}