mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:25:20 +00:00
Fix CI media-wipe race, per-link ping rate limiting, and /ping output routing
Three fixes for PR #1377 review: 1. CI flake (sendImage_privateChatProcessesAndTransfersImage): the panicClearAllData / clearCurrentPublicTimeline detached utility-priority tasks delete the real ~/Library/Application Support/files tree, which the test process shares. The wipe fires at a nondeterministic time and raced the sendImage test's JPEG in files/images/outgoing (write then re-read), so prepareImagePacket threw and the test timed out. Both wipes are now skipped under tests (existing TestEnvironment.isRunningTests pattern); this also stops test runs from deleting the developer's real media. 2. Codex P1: ping packets are unsigned, so keying the pong rate limiter on packet.senderID let one connected peer rotate forged sender IDs to bypass the 5-per-10s budget. The limiter now keys on the ingress link (the directly connected peer that delivered the packet); the pong still goes to the claimed sender. Regression test proves rotating senders over one link exhaust one budget (fails 10 vs 5 pongs on the old code). 3. Codex P2: /ping output arrived up to 10s later and was routed from selectedPrivateChatPeer at callback time, misrouting the result after a chat switch. The origin conversation is now captured when the command is issued (CommandOutputDestination) and deferred output is routed there: a DM result lands in the origin chat's history even if deselected, and a mesh-timeline result pins to #mesh instead of the active channel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -216,6 +216,69 @@ struct BLEServiceCoreTests {
|
||||
cachedServiceUUIDs: [BLEService.serviceUUID, otherService]
|
||||
))
|
||||
}
|
||||
|
||||
/// Pings are unsigned, so their claimed sender is attacker-controlled.
|
||||
/// The pong budget must be keyed on the ingress link (the directly
|
||||
/// connected peer that delivered the packet): rotating forged sender IDs
|
||||
/// over one link exhausts one budget instead of resetting it, so a single
|
||||
/// malicious link cannot turn /ping into an amplification primitive.
|
||||
@Test
|
||||
func meshPingResponseBudget_isPerIngressLinkNotClaimedSender() async throws {
|
||||
let ble = makeService()
|
||||
let outbound = OutboundPacketTap()
|
||||
ble._test_onOutboundPacket = outbound.record
|
||||
|
||||
let link = PeerID(str: "1122334455667788")
|
||||
let budget = TransportConfig.meshPingInboundMaxPerLink
|
||||
let myRecipientData = try #require(Data(hexString: ble.myPeerID.id))
|
||||
|
||||
for i in 0..<(budget * 2) {
|
||||
// A fresh forged sender for every ping, all arriving on one link.
|
||||
let forgedSender = PeerID(str: String(format: "%016x", 0xA0_0000 + i))
|
||||
var nonce = Data(repeating: 0, count: MeshPingPayload.nonceLength)
|
||||
nonce[0] = UInt8(i)
|
||||
let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 7))
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.ping.rawValue,
|
||||
senderID: Data(hexString: forgedSender.id) ?? Data(),
|
||||
recipientID: myRecipientData,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload.encode(),
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
ble._test_handlePacket(packet, fromPeerID: link, preseedPeer: false)
|
||||
}
|
||||
|
||||
let reachedBudget = await TestHelpers.waitUntil(
|
||||
{ outbound.count(ofType: .pong) >= budget },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(reachedBudget)
|
||||
// Give any over-budget pong a chance to surface, then confirm the
|
||||
// rotated sender IDs never bought a sixth response.
|
||||
let exceededBudget = await TestHelpers.waitUntil(
|
||||
{ outbound.count(ofType: .pong) > budget },
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!exceededBudget)
|
||||
#expect(outbound.count(ofType: .pong) == budget)
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe capture of packets leaving the service under test.
|
||||
private final class OutboundPacketTap {
|
||||
private let lock = NSLock()
|
||||
private var packets: [BitchatPacket] = []
|
||||
|
||||
func record(_ packet: BitchatPacket) {
|
||||
lock.lock(); packets.append(packet); lock.unlock()
|
||||
}
|
||||
|
||||
func count(ofType type: MessageType) -> Int {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return packets.filter { $0.type == type.rawValue }.count
|
||||
}
|
||||
}
|
||||
|
||||
private func makeService() -> BLEService {
|
||||
|
||||
@@ -436,6 +436,7 @@ private final class MockCommandContextProvider: CommandContextProvider {
|
||||
private(set) var localPrivateSystemMessages: [(content: String, peerID: PeerID)] = []
|
||||
private(set) var publicSystemMessages: [String] = []
|
||||
private(set) var commandOutputs: [String] = []
|
||||
private(set) var commandOutputDestinations: [CommandOutputDestination] = []
|
||||
private(set) var toggledFavorites: [PeerID] = []
|
||||
private(set) var favoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
|
||||
|
||||
@@ -486,8 +487,16 @@ private final class MockCommandContextProvider: CommandContextProvider {
|
||||
publicSystemMessages.append(content)
|
||||
}
|
||||
|
||||
func addCommandOutput(_ content: String) {
|
||||
func currentCommandDestination() -> CommandOutputDestination {
|
||||
if let peerID = selectedPrivateChatPeer {
|
||||
return .privateChat(peerID)
|
||||
}
|
||||
return .meshTimeline
|
||||
}
|
||||
|
||||
func addCommandOutput(_ content: String, to destination: CommandOutputDestination) {
|
||||
commandOutputs.append(content)
|
||||
commandOutputDestinations.append(destination)
|
||||
}
|
||||
|
||||
func toggleFavorite(peerID: PeerID) {
|
||||
|
||||
@@ -129,6 +129,44 @@ struct MeshDiagnosticsTests {
|
||||
#expect(context.commandOutputs == ["no reply from alice"])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func pingOutputRoutesToConversationWhereCommandWasIssued() async {
|
||||
let context = DiagnosticsMockContext()
|
||||
let alice = PeerID(str: "abcd1234abcd1234")
|
||||
let bob = PeerID(str: "b0b0b0b0b0b0b0b0")
|
||||
context.nicknameToPeerID["alice"] = alice
|
||||
let transport = MockTransport()
|
||||
transport.meshPingResult = MeshPingResult(rttMs: 42, hops: 2)
|
||||
let processor = makeProcessor(context: context, transport: transport)
|
||||
|
||||
// Issue the ping from bob's DM, then switch chats before the async
|
||||
// result lands. The output must follow the origin conversation, not
|
||||
// whatever is selected at callback time.
|
||||
context.selectedPrivateChatPeer = bob
|
||||
_ = processor.process("/ping @alice")
|
||||
context.selectedPrivateChatPeer = nil
|
||||
|
||||
await waitForCommandOutput(context)
|
||||
#expect(context.commandOutputDestinations == [.privateChat(bob)])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func pingIssuedFromPublicTimelineRoutesToMeshTimeline() async {
|
||||
let context = DiagnosticsMockContext()
|
||||
let alice = PeerID(str: "abcd1234abcd1234")
|
||||
context.nicknameToPeerID["alice"] = alice
|
||||
let transport = MockTransport()
|
||||
transport.meshPingResult = MeshPingResult(rttMs: 7, hops: 1)
|
||||
let processor = makeProcessor(context: context, transport: transport)
|
||||
|
||||
_ = processor.process("/ping @alice")
|
||||
// Opening a DM afterwards must not swallow the public-timeline result.
|
||||
context.selectedPrivateChatPeer = alice
|
||||
|
||||
await waitForCommandOutput(context)
|
||||
#expect(context.commandOutputDestinations == [.meshTimeline])
|
||||
}
|
||||
|
||||
// MARK: - /trace
|
||||
|
||||
@MainActor
|
||||
@@ -225,6 +263,7 @@ private final class DiagnosticsMockContext: CommandContextProvider {
|
||||
|
||||
var nicknameToPeerID: [String: PeerID] = [:]
|
||||
private(set) var commandOutputs: [String] = []
|
||||
private(set) var commandOutputDestinations: [CommandOutputDestination] = []
|
||||
|
||||
func getPeerIDForNickname(_ nickname: String) -> PeerID? {
|
||||
nicknameToPeerID[nickname]
|
||||
@@ -241,7 +280,15 @@ private final class DiagnosticsMockContext: CommandContextProvider {
|
||||
func addPublicSystemMessage(_ content: String) {}
|
||||
func toggleFavorite(peerID: PeerID) {}
|
||||
|
||||
func addCommandOutput(_ content: String) {
|
||||
func currentCommandDestination() -> CommandOutputDestination {
|
||||
if let peerID = selectedPrivateChatPeer {
|
||||
return .privateChat(peerID)
|
||||
}
|
||||
return .meshTimeline
|
||||
}
|
||||
|
||||
func addCommandOutput(_ content: String, to destination: CommandOutputDestination) {
|
||||
commandOutputs.append(content)
|
||||
commandOutputDestinations.append(destination)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user