mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 21:45:20 +00:00
Add mesh diagnostics: /ping, /trace, and topology map (#1377)
* Add mesh diagnostics: /ping, /trace, and topology map - New protocol types ping=0x26 / pong=0x27 (9-byte payload: 8-byte nonce + origin TTL) with per-peer inbound rate limiting (5 per 10s) - /ping @name reports RTT and hop count, 10s timeout - /trace @name prints the estimated path from gossiped directNeighbors - Topology map sheet (circular Canvas layout) reachable from App Info - Ping/pong ride the deterministic directed-relay path like DMs - Tests: payload round-trip, hop-count math, command output, edge normalization, layout Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 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> * App Info: move NETWORK section under HOW TO USE and uppercase NETWORK/SYMBOLS headers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Conform DiagnosticsMockContext to sendPublicMessage CommandContextProvider gained sendPublicMessage (Cashu /pay, #1376) after this branch forked, so the diagnostics test mock no longer conformed once main was merged in. Add the no-op stub. 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:
co-authored by
jack
Claude Fable 5
parent
d4f0c49787
commit
f9032cf2b9
@@ -62,6 +62,28 @@ final class AppChromeModel: ObservableObject {
|
||||
isAppInfoPresented = true
|
||||
}
|
||||
|
||||
/// Builds the mesh topology map model from the transport's gossiped
|
||||
/// graph plus the live nickname table. Unknown nodes (heard about via a
|
||||
/// neighbor claim but never announced to us) fall back to a short ID.
|
||||
func meshTopologyDisplayModel() -> MeshTopologyDisplayModel {
|
||||
let mesh = chatViewModel.meshService
|
||||
guard let snapshot = mesh.currentMeshTopology() else { return .empty }
|
||||
let nicknames = mesh.getPeerNicknames()
|
||||
|
||||
let nodes = snapshot.nodes.map { peerID -> MeshTopologyDisplayModel.Node in
|
||||
let isSelf = peerID == snapshot.localPeerID
|
||||
let label: String
|
||||
if isSelf {
|
||||
label = chatViewModel.nickname
|
||||
} else {
|
||||
label = nicknames[peerID] ?? "\(peerID.id.prefix(8))…"
|
||||
}
|
||||
return MeshTopologyDisplayModel.Node(id: peerID.id, label: label, isSelf: isSelf)
|
||||
}
|
||||
let edges = snapshot.edges.map { ($0.a.id, $0.b.id) }
|
||||
return MeshTopologyDisplayModel(nodes: nodes, edges: edges)
|
||||
}
|
||||
|
||||
func triggerScreenshotPrivacyWarning() {
|
||||
showScreenshotPrivacyWarning = true
|
||||
}
|
||||
|
||||
@@ -5142,7 +5142,7 @@
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "symbols"
|
||||
"value" : "SYMBOLS"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5171,6 +5171,54 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"app_info.network.title" : {
|
||||
"comment" : "Section header for network diagnostics in the app info sheet",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "NETWORK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"app_info.network.topology.description" : {
|
||||
"comment" : "Row description for the mesh topology map",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "map of peers and links learned from mesh announces"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"app_info.network.topology.hint" : {
|
||||
"comment" : "Accessibility hint for the mesh topology row",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "opens the mesh topology map"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"app_info.network.topology.title" : {
|
||||
"comment" : "Row title opening the mesh topology map",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "mesh topology"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"app_info.privacy.ephemeral.description" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -15449,6 +15497,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.commands.ping" : {
|
||||
"comment" : "Description of the /ping command in the suggestions panel",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "measure round-trip time to a mesh peer"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.commands.slap" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -15628,6 +15688,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.commands.trace" : {
|
||||
"comment" : "Description of the /trace command in the suggestions panel",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "estimate the mesh path to a peer"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"content.commands.unblock" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -35384,6 +35456,66 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"topology.caption" : {
|
||||
"comment" : "Caption under the mesh topology map",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "estimated from gossiped neighbor lists (up to 10 per peer) — your device is highlighted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"topology.empty" : {
|
||||
"comment" : "Empty state of the mesh topology map",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "no mesh links yet — the map fills in as peer announces arrive"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"topology.refresh" : {
|
||||
"comment" : "Accessibility label of the topology refresh button",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "refresh topology"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"topology.summary" : {
|
||||
"comment" : "Topology map summary: number of peers and links",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "%1$ld peers · %2$ld links"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"topology.title" : {
|
||||
"comment" : "Title of the mesh topology map sheet",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "mesh topology"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"verification.my_qr.accessibility_label" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
|
||||
@@ -25,6 +25,8 @@ enum CommandInfo: String, Identifiable {
|
||||
case who
|
||||
case favorite = "fav"
|
||||
case unfavorite = "unfav"
|
||||
case ping
|
||||
case trace
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
@@ -32,7 +34,7 @@ enum CommandInfo: String, Identifiable {
|
||||
|
||||
var placeholder: String? {
|
||||
switch self {
|
||||
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
|
||||
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite, .ping, .trace:
|
||||
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
||||
case .pay:
|
||||
return "<" + String(localized: "content.input.token_placeholder") + ">"
|
||||
@@ -54,6 +56,8 @@ enum CommandInfo: String, Identifiable {
|
||||
case .who: String(localized: "content.commands.who")
|
||||
case .favorite: String(localized: "content.commands.favorite")
|
||||
case .unfavorite: String(localized: "content.commands.unfavorite")
|
||||
case .ping: String(localized: "content.commands.ping")
|
||||
case .trace: String(localized: "content.commands.trace")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,11 +70,11 @@ enum CommandInfo: String, Identifiable {
|
||||
if !isGeoPublic {
|
||||
commands.append(.pay)
|
||||
}
|
||||
// The processor rejects favorites in geohash contexts, so only
|
||||
// suggest them where they actually work: mesh.
|
||||
// The processor rejects favorites and mesh diagnostics in geohash
|
||||
// contexts, so only suggest them where they actually work: mesh.
|
||||
if isGeoPublic || isGeoDM {
|
||||
return commands
|
||||
}
|
||||
return commands + [.favorite, .unfavorite]
|
||||
return commands + [.favorite, .unfavorite, .ping, .trace]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
|
||||
switch MessageType(rawValue: packetType) {
|
||||
case .noiseEncrypted, .noiseHandshake:
|
||||
return true
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .nostrCarrier:
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,10 +51,15 @@ struct BLEReceivePipeline {
|
||||
// Courier envelopes are directed opaque ciphertext like DMs; a
|
||||
// remote handover toward a relayed announce rides this same
|
||||
// deterministic relay treatment instead of the broadcast clamp.
|
||||
// Ping/pong diagnostics ride it too: probes need the same
|
||||
// deterministic multi-hop relay as DMs (always relay, jitter,
|
||||
// no TTL cap) so RTT and hop counts reflect the real path.
|
||||
// Directed nostrCarrier uplinks (mesh-only peer -> gateway) need
|
||||
// the same multi-hop treatment to reach a non-adjacent gateway.
|
||||
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue
|
||||
|| packet.type == MessageType.courierEnvelope.rawValue
|
||||
|| packet.type == MessageType.ping.rawValue
|
||||
|| packet.type == MessageType.pong.rawValue
|
||||
|| packet.type == MessageType.nostrCarrier.rawValue) && packet.recipientID != nil,
|
||||
isFragment: packet.type == MessageType.fragment.rawValue,
|
||||
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
||||
|
||||
@@ -80,6 +80,24 @@ final class BLEService: NSObject {
|
||||
// Route health for originated source routes; guarded by collectionsQueue.
|
||||
private var sourceRouteFailures = BLESourceRouteFailureCache()
|
||||
|
||||
// Mesh diagnostics: outstanding /ping probes keyed by nonce, plus the
|
||||
// inbound ping budget — keyed by the ingress link (the directly connected
|
||||
// peer that delivered the packet), since the unsigned claimed sender is
|
||||
// spoofable — so a directed unencrypted probe cannot be turned into an
|
||||
// amplification primitive. Both are owned by collectionsQueue barriers
|
||||
// like the other mutable collections.
|
||||
private struct PendingMeshPing {
|
||||
let peerID: PeerID
|
||||
let sentAt: Date
|
||||
let completion: @MainActor (MeshPingResult?) -> Void
|
||||
let timeout: DispatchWorkItem
|
||||
}
|
||||
private var pendingMeshPings: [Data: PendingMeshPing] = [:]
|
||||
private var meshPingResponseLimiter = SyncResponseRateLimiter(
|
||||
maxResponses: TransportConfig.meshPingInboundMaxPerLink,
|
||||
window: TransportConfig.meshPingInboundWindowSeconds
|
||||
)
|
||||
|
||||
// 5. Fragment Reassembly (necessary for messages > MTU)
|
||||
private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer()
|
||||
private var outboundFragmentTransfers = BLEOutboundFragmentTransferScheduler()
|
||||
@@ -2493,6 +2511,150 @@ extension BLEService {
|
||||
PeerID(routingData: data)
|
||||
}
|
||||
|
||||
// MARK: - Mesh Diagnostics (/ping, /trace, topology map)
|
||||
|
||||
/// Sends a directed unencrypted ping probe (8-byte nonce + origin TTL).
|
||||
/// The completion fires exactly once on the main actor: with RTT/hops
|
||||
/// when the matching pong returns, or nil after the timeout window.
|
||||
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) {
|
||||
messageQueue.async { [weak self] in
|
||||
guard let self,
|
||||
let recipientData = peerID.toShort().routingData,
|
||||
let payload = MeshPingPayload(
|
||||
nonce: Data((0..<MeshPingPayload.nonceLength).map { _ in UInt8.random(in: .min ... .max) }),
|
||||
originTTL: self.messageTTL
|
||||
) else {
|
||||
Task { @MainActor in completion(nil) }
|
||||
return
|
||||
}
|
||||
let nonce = payload.nonce
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.ping.rawValue,
|
||||
senderID: self.myPeerIDData,
|
||||
recipientID: recipientData,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload.encode(),
|
||||
signature: nil,
|
||||
ttl: self.messageTTL
|
||||
)
|
||||
let timeout = DispatchWorkItem { [weak self] in
|
||||
guard let self else { return }
|
||||
let expired = self.collectionsQueue.sync(flags: .barrier) {
|
||||
self.pendingMeshPings.removeValue(forKey: nonce)
|
||||
}
|
||||
guard let expired else { return }
|
||||
Task { @MainActor in expired.completion(nil) }
|
||||
}
|
||||
self.collectionsQueue.sync(flags: .barrier) {
|
||||
self.pendingMeshPings[nonce] = PendingMeshPing(
|
||||
peerID: PeerID(hexData: recipientData),
|
||||
sentAt: Date(),
|
||||
completion: completion,
|
||||
timeout: timeout
|
||||
)
|
||||
}
|
||||
self.messageQueue.asyncAfter(
|
||||
deadline: .now() + TransportConfig.meshPingTimeoutSeconds,
|
||||
execute: timeout
|
||||
)
|
||||
self.broadcastPacket(packet)
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers a ping addressed to us with a pong echoing its nonce; pings
|
||||
/// addressed elsewhere are left to the generic directed-relay path.
|
||||
///
|
||||
/// `linkPeerID` is the directly connected peer that delivered the packet
|
||||
/// (the ingress link), NOT the packet's claimed sender: pings are
|
||||
/// unsigned, so `packet.senderID` is attacker-controlled, and keying the
|
||||
/// response budget on it would let one connected peer rotate forged
|
||||
/// sender IDs to emit unbounded pongs. The budget is per physical link;
|
||||
/// the pong still goes to the claimed sender (that's the protocol).
|
||||
private func handleMeshPing(_ packet: BitchatPacket, fromLink linkPeerID: PeerID) {
|
||||
guard packet.recipientID == myPeerIDData else { return }
|
||||
guard let ping = MeshPingPayload.decode(packet.payload) else {
|
||||
SecureLogger.debug("⚠️ Malformed ping via \(linkPeerID.id.prefix(8))…", category: .session)
|
||||
return
|
||||
}
|
||||
let allowed = collectionsQueue.sync(flags: .barrier) {
|
||||
meshPingResponseLimiter.shouldRespond(to: linkPeerID, now: Date())
|
||||
}
|
||||
guard allowed else {
|
||||
if logRateLimiter.shouldLog(key: "ping-limit:\(linkPeerID.id)") {
|
||||
SecureLogger.warning("🚫 Rate-limiting pings via link \(linkPeerID.id.prefix(8))…", category: .security)
|
||||
}
|
||||
return
|
||||
}
|
||||
guard let pong = MeshPingPayload(nonce: ping.nonce, originTTL: messageTTL) else { return }
|
||||
let reply = BitchatPacket(
|
||||
type: MessageType.pong.rawValue,
|
||||
senderID: myPeerIDData,
|
||||
recipientID: packet.senderID,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: pong.encode(),
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
broadcastPacket(reply)
|
||||
}
|
||||
|
||||
/// Resolves a pong against its outstanding probe. The unguessable echoed
|
||||
/// nonce plus the sender check bind the reply to the probed peer; hops
|
||||
/// come from the pong's TTL decrements on the return path.
|
||||
private func handleMeshPong(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
guard packet.recipientID == myPeerIDData else { return }
|
||||
guard let pong = MeshPingPayload.decode(packet.payload) else { return }
|
||||
let pending = collectionsQueue.sync(flags: .barrier) { () -> PendingMeshPing? in
|
||||
guard pendingMeshPings[pong.nonce]?.peerID == peerID else { return nil }
|
||||
return pendingMeshPings.removeValue(forKey: pong.nonce)
|
||||
}
|
||||
guard let pending else { return }
|
||||
pending.timeout.cancel()
|
||||
let rttMs = Int((Date().timeIntervalSince(pending.sentAt) * 1000).rounded())
|
||||
let result = MeshPingResult(
|
||||
rttMs: max(0, rttMs),
|
||||
hops: MeshPingPayload.hopCount(originTTL: pong.originTTL, receivedTTL: packet.ttl)
|
||||
)
|
||||
Task { @MainActor in pending.completion(result) }
|
||||
}
|
||||
|
||||
/// Estimated intermediate hops toward `peerID`, BFS over gossiped
|
||||
/// bidirectionally-confirmed neighbor claims ([] = direct, nil = none).
|
||||
func computeMeshPath(to peerID: PeerID) -> [PeerID]? {
|
||||
refreshLocalTopology()
|
||||
if let route = computeRoute(to: peerID) {
|
||||
return route.compactMap { PeerID(routingData: $0) }
|
||||
}
|
||||
// Confirmed claims can lag a brand-new link (the peer's next announce
|
||||
// hasn't arrived yet); a live direct connection is still a known path.
|
||||
return isPeerConnected(peerID) ? [] : nil
|
||||
}
|
||||
|
||||
/// Mesh graph for the topology map. Edges are advisory: announces cap
|
||||
/// neighbor lists at 10, so an edge claimed by either endpoint counts.
|
||||
func currentMeshTopology() -> MeshTopologySnapshot? {
|
||||
refreshLocalTopology()
|
||||
let claims = meshTopology.adjacencySnapshot()
|
||||
var nodes = Set<PeerID>()
|
||||
var edges = Set<MeshTopologyEdge>()
|
||||
for (source, neighbors) in claims {
|
||||
guard let sourcePeer = PeerID(routingData: source) else { continue }
|
||||
nodes.insert(sourcePeer)
|
||||
for neighborData in neighbors {
|
||||
guard let neighborPeer = PeerID(routingData: neighborData),
|
||||
neighborPeer != sourcePeer else { continue }
|
||||
nodes.insert(neighborPeer)
|
||||
edges.insert(MeshTopologyEdge(sourcePeer, neighborPeer))
|
||||
}
|
||||
}
|
||||
nodes.insert(myPeerID)
|
||||
return MeshTopologySnapshot(
|
||||
localPeerID: myPeerID,
|
||||
nodes: nodes.sorted(),
|
||||
edges: edges.sorted { ($0.a, $0.b) < ($1.a, $1.b) }
|
||||
)
|
||||
}
|
||||
|
||||
private func forwardAlongRouteIfNeeded(_ packet: BitchatPacket) -> Bool {
|
||||
let myRoutingData = routingData(for: myPeerID) ?? (myPeerIDData.isEmpty ? nil : myPeerIDData)
|
||||
let plan = BLERouteForwardingPolicy.plan(
|
||||
@@ -3381,6 +3543,15 @@ extension BLEService {
|
||||
case .nostrCarrier:
|
||||
handleNostrCarrier(packet, from: peerID)
|
||||
|
||||
case .ping:
|
||||
// Rate limiting must key on the ingress link (`peerID`), not the
|
||||
// packet-claimed sender: pings are unsigned, so `senderID` is
|
||||
// attacker-controlled and rotating it would reset the budget.
|
||||
handleMeshPing(packet, fromLink: peerID)
|
||||
|
||||
case .pong:
|
||||
handleMeshPong(packet, from: senderID)
|
||||
|
||||
case .leave:
|
||||
handleLeave(packet, from: senderID)
|
||||
|
||||
|
||||
@@ -22,6 +22,17 @@ struct CommandGeoParticipant {
|
||||
let displayName: String
|
||||
}
|
||||
|
||||
/// The conversation a command was typed into, captured when the command is
|
||||
/// issued so deferred output (e.g. an async /ping result, which can arrive
|
||||
/// many seconds later) lands there even if the user switches chats first.
|
||||
enum CommandOutputDestination: Equatable {
|
||||
/// The #mesh public timeline. Commands that defer output (/ping) are
|
||||
/// mesh-only, so a non-DM origin is always the mesh timeline.
|
||||
case meshTimeline
|
||||
/// The private chat that was open when the command was typed.
|
||||
case privateChat(PeerID)
|
||||
}
|
||||
|
||||
/// Protocol defining what CommandProcessor needs from its context.
|
||||
/// This breaks the circular dependency between CommandProcessor and ChatViewModel.
|
||||
@MainActor
|
||||
@@ -51,6 +62,13 @@ protocol CommandContextProvider: AnyObject {
|
||||
// MARK: - System Messages
|
||||
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID)
|
||||
func addPublicSystemMessage(_ content: String)
|
||||
/// The conversation the user is typing into right now. Commands that
|
||||
/// finish asynchronously capture this BEFORE starting async work, so a
|
||||
/// chat switch cannot misroute their deferred output.
|
||||
func currentCommandDestination() -> CommandOutputDestination
|
||||
/// Routes deferred command output (e.g. an async /ping result) into the
|
||||
/// conversation captured when the command was issued.
|
||||
func addCommandOutput(_ content: String, to destination: CommandOutputDestination)
|
||||
|
||||
// MARK: - Favorites
|
||||
/// Toggles the favorite via the unified peer flow, which persists by the
|
||||
@@ -108,6 +126,12 @@ final class CommandProcessor {
|
||||
case "/unfav":
|
||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||
return handleFavorite(args, add: false)
|
||||
case "/ping":
|
||||
if inGeoPublic || inGeoDM { return .error(message: "ping only works for mesh peers in #mesh") }
|
||||
return handlePing(args)
|
||||
case "/trace":
|
||||
if inGeoPublic || inGeoDM { return .error(message: "trace only works for mesh peers in #mesh") }
|
||||
return handleTrace(args)
|
||||
case "/pay":
|
||||
return handlePay(args)
|
||||
case "/help":
|
||||
@@ -129,6 +153,8 @@ final class CommandProcessor {
|
||||
/slap @name — slap with a large trout
|
||||
/block @name · /unblock @name
|
||||
/fav @name · /unfav @name — favorites (mesh only)
|
||||
/ping @name — measure round-trip time (mesh only)
|
||||
/trace @name — estimated mesh path (mesh only)
|
||||
/pay <token> — send a cashu ecash token in this chat
|
||||
/help — this list
|
||||
"""
|
||||
@@ -336,6 +362,76 @@ final class CommandProcessor {
|
||||
return .error(message: "cannot unblock \(nickname): not found")
|
||||
}
|
||||
|
||||
// MARK: - Mesh Diagnostics
|
||||
|
||||
private enum MeshPeerResolution {
|
||||
case resolved(peerID: PeerID, nickname: String)
|
||||
case failed(CommandResult)
|
||||
}
|
||||
|
||||
/// Resolves a mesh peer for /ping and /trace. Geohash identities are
|
||||
/// rejected — diagnostics measure the BLE mesh, not Nostr.
|
||||
private func resolveMeshPeer(_ args: String, command: String) -> MeshPeerResolution {
|
||||
let targetName = args.trimmed
|
||||
guard !targetName.isEmpty else {
|
||||
return .failed(.error(message: "usage: /\(command) <nickname>"))
|
||||
}
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
||||
!peerID.isGeoDM, !peerID.isGeoChat else {
|
||||
return .failed(.error(message: "cannot \(command) \(nickname): not found on mesh"))
|
||||
}
|
||||
return .resolved(peerID: peerID, nickname: nickname)
|
||||
}
|
||||
|
||||
private func handlePing(_ args: String) -> CommandResult {
|
||||
let target: (peerID: PeerID, nickname: String)
|
||||
switch resolveMeshPeer(args, command: "ping") {
|
||||
case .resolved(let peerID, let nickname): target = (peerID, nickname)
|
||||
case .failed(let result): return result
|
||||
}
|
||||
|
||||
let nickname = target.nickname
|
||||
let currentProvider = contextProvider
|
||||
// Capture the origin conversation now: the pong can arrive up to
|
||||
// meshPingTimeoutSeconds later, and reading the selected chat at
|
||||
// callback time would misroute the result after a chat switch.
|
||||
let destination = contextProvider?.currentCommandDestination() ?? .meshTimeline
|
||||
meshService?.sendMeshPing(to: target.peerID) { [weak currentProvider] result in
|
||||
let provider = currentProvider
|
||||
guard let result else {
|
||||
provider?.addCommandOutput("no reply from \(nickname)", to: destination)
|
||||
return
|
||||
}
|
||||
let hopText: String = result.hops.map { hops in
|
||||
hops == 1 ? " · direct (1 hop)" : " · \(hops) hops"
|
||||
} ?? ""
|
||||
provider?.addCommandOutput("pong from \(nickname): \(result.rttMs) ms\(hopText)", to: destination)
|
||||
}
|
||||
return .success(message: "pinging \(nickname)…")
|
||||
}
|
||||
|
||||
private func handleTrace(_ args: String) -> CommandResult {
|
||||
let target: (peerID: PeerID, nickname: String)
|
||||
switch resolveMeshPeer(args, command: "trace") {
|
||||
case .resolved(let peerID, let nickname): target = (peerID, nickname)
|
||||
case .failed(let result): return result
|
||||
}
|
||||
|
||||
guard let mesh = meshService,
|
||||
let intermediates = mesh.computeMeshPath(to: target.peerID) else {
|
||||
return .success(message: "no known path to \(target.nickname)")
|
||||
}
|
||||
// Graph-derived from gossiped neighbor claims, not route-recorded —
|
||||
// present it as an estimate.
|
||||
let hopNames = intermediates.map { hop in
|
||||
mesh.peerNickname(peerID: hop) ?? "\(hop.id.prefix(8))…"
|
||||
}
|
||||
let chain = (["you"] + hopNames + [target.nickname]).joined(separator: " → ")
|
||||
let hops = intermediates.count + 1
|
||||
return .success(message: "estimated path: \(chain) (\(hops) hop\(hops == 1 ? "" : "s"))")
|
||||
}
|
||||
|
||||
/// `/pay <cashu-token>` — validates the token decodes, then sends it as
|
||||
/// the message body in the current chat. Cashu tokens are bearer
|
||||
/// instruments (whoever redeems first gets the funds), so posting one to
|
||||
|
||||
@@ -49,6 +49,13 @@ final class MeshTopologyTracker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw directed neighbor claims, for diagnostics (topology map, /trace).
|
||||
/// Callers treat the claims as advisory: announces cap `directNeighbors`
|
||||
/// at 10, so an edge may be claimed by only one of its endpoints.
|
||||
func adjacencySnapshot() -> [Data: Set<Data>] {
|
||||
queue.sync { claims }
|
||||
}
|
||||
|
||||
func removePeer(_ data: Data?) {
|
||||
guard let peer = sanitize(data) else { return }
|
||||
queue.sync(flags: .barrier) {
|
||||
|
||||
@@ -31,6 +31,40 @@ struct TransportPeerSnapshot: Equatable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of a `/ping` probe over the mesh.
|
||||
struct MeshPingResult: Equatable {
|
||||
/// Round-trip time in milliseconds.
|
||||
let rttMs: Int
|
||||
/// Total hops to the peer (1 = directly connected), derived from the
|
||||
/// pong's TTL decrements; nil when the reply carried inconsistent TTLs.
|
||||
let hops: Int?
|
||||
}
|
||||
|
||||
/// Undirected mesh link between two peers, normalized so `(a, b)` and
|
||||
/// `(b, a)` collapse to one edge.
|
||||
struct MeshTopologyEdge: Hashable {
|
||||
let a: PeerID
|
||||
let b: PeerID
|
||||
|
||||
init(_ first: PeerID, _ second: PeerID) {
|
||||
if first < second {
|
||||
a = first
|
||||
b = second
|
||||
} else {
|
||||
a = second
|
||||
b = first
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Point-in-time view of the mesh graph learned from gossiped announces
|
||||
/// (each announce carries up to 10 `directNeighbors`).
|
||||
struct MeshTopologySnapshot: Equatable {
|
||||
let localPeerID: PeerID
|
||||
let nodes: [PeerID]
|
||||
let edges: [MeshTopologyEdge]
|
||||
}
|
||||
|
||||
enum TransportEvent: @unchecked Sendable {
|
||||
case messageReceived(BitchatMessage)
|
||||
case publicMessageReceived(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
||||
@@ -128,6 +162,17 @@ protocol Transport: AnyObject {
|
||||
// payload (post or tombstone) so it spreads over relay and gossip sync.
|
||||
func sendBoardPayload(_ payload: Data)
|
||||
|
||||
// Mesh diagnostics (optional for transports). Defaults are inert so
|
||||
// queue-backed transports (e.g. NostrTransport) stay untouched.
|
||||
/// Sends a directed ping probe; the completion fires exactly once on the
|
||||
/// main actor with the measured result, or nil on timeout/unsupported.
|
||||
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void)
|
||||
/// Estimated intermediate hops toward `peerID` from gossiped topology
|
||||
/// ([] = direct link, nil = no known path).
|
||||
func computeMeshPath(to peerID: PeerID) -> [PeerID]?
|
||||
/// Current mesh graph for the topology map; nil when unsupported.
|
||||
func currentMeshTopology() -> MeshTopologySnapshot?
|
||||
|
||||
// QR verification (optional for transports)
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
@@ -174,6 +219,14 @@ extension Transport {
|
||||
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
|
||||
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
|
||||
func sendBoardPayload(_ payload: Data) {}
|
||||
|
||||
// Mesh diagnostics are mesh-transport-only; other transports report
|
||||
// "no reply"/"no path" rather than pretending to measure anything.
|
||||
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) {
|
||||
Task { @MainActor in completion(nil) }
|
||||
}
|
||||
func computeMeshPath(to peerID: PeerID) -> [PeerID]? { nil }
|
||||
func currentMeshTopology() -> MeshTopologySnapshot? { nil }
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
||||
func cancelTransfer(_ transferId: String) {}
|
||||
|
||||
@@ -16,6 +16,11 @@ enum TransportConfig {
|
||||
static let bleFragmentRelayTtlCap: UInt8 = 7
|
||||
static let bleFragmentRelayTtlCapDense: UInt8 = 5 // Contain fragment floods in dense graphs
|
||||
|
||||
// Mesh diagnostics (/ping)
|
||||
static let meshPingTimeoutSeconds: TimeInterval = 10 // Give up on a probe after this window
|
||||
static let meshPingInboundMaxPerLink: Int = 5 // Inbound ping budget per ingress link (claimed sender is spoofable)...
|
||||
static let meshPingInboundWindowSeconds: TimeInterval = 10 // ...per sliding window (anti-amplification)
|
||||
|
||||
// UI / Storage Caps
|
||||
static let privateChatCap: Int = 1337
|
||||
static let meshTimelineCap: Int = 1337
|
||||
|
||||
@@ -40,6 +40,9 @@ struct SyncTypeFlags: OptionSet {
|
||||
// Courier envelopes are directed deposits between trusted peers and
|
||||
// must never spread via gossip sync.
|
||||
case .courierEnvelope: return nil
|
||||
// Ping/pong are ephemeral directed probes; replaying them via gossip
|
||||
// sync would only produce stale, unanswerable echoes.
|
||||
case .ping, .pong: return nil
|
||||
// Gateway carriers are ephemeral live traffic (uplinks are directed,
|
||||
// downlinks are rate-budgeted rebroadcasts); replaying them via sync
|
||||
// would waste airtime and extend their lifetime.
|
||||
|
||||
@@ -1547,6 +1547,33 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
}
|
||||
}
|
||||
|
||||
/// Origin conversation for deferred command output, captured when the
|
||||
/// command is issued (before any async work starts).
|
||||
@MainActor
|
||||
func currentCommandDestination() -> CommandOutputDestination {
|
||||
if let peerID = selectedPrivateChatPeer {
|
||||
return .privateChat(peerID)
|
||||
}
|
||||
// Deferring commands (/ping) are rejected in geohash channels, so a
|
||||
// non-DM origin is always the #mesh timeline.
|
||||
return .meshTimeline
|
||||
}
|
||||
|
||||
/// Routes deferred command output (async /ping results) into the
|
||||
/// conversation captured at issue time, immune to chat switches in the
|
||||
/// meantime. A DM result lands in the origin chat's history even if that
|
||||
/// chat is no longer selected (or was cleared — it then reappears as the
|
||||
/// first message when the chat is reopened).
|
||||
@MainActor
|
||||
func addCommandOutput(_ content: String, to destination: CommandOutputDestination) {
|
||||
switch destination {
|
||||
case .privateChat(let peerID):
|
||||
addLocalPrivateSystemMessage(content, to: peerID)
|
||||
case .meshTimeline:
|
||||
publicConversationCoordinator.addMeshOnlySystemMessage(content)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Message Reception
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -5,6 +5,11 @@ struct AppInfoView: View {
|
||||
@ThemedPalette private var palette
|
||||
@AppStorage(AppTheme.storageKey) private var appThemeRawValue = AppTheme.matrix.rawValue
|
||||
|
||||
/// Supplies the mesh topology map data. Nil (previews, missing wiring)
|
||||
/// hides the topology row entirely.
|
||||
var topologyProvider: (@MainActor () -> MeshTopologyDisplayModel)?
|
||||
@State private var showTopology = false
|
||||
|
||||
private var selectedTheme: AppTheme {
|
||||
AppTheme(rawValue: appThemeRawValue) ?? .matrix
|
||||
}
|
||||
@@ -75,6 +80,15 @@ struct AppInfoView: View {
|
||||
]
|
||||
}
|
||||
|
||||
enum Network {
|
||||
static let title: LocalizedStringKey = "app_info.network.title"
|
||||
static let topology = AppInfoFeatureInfo(
|
||||
icon: "point.3.connected.trianglepath.dotted",
|
||||
title: "app_info.network.topology.title",
|
||||
description: "app_info.network.topology.description"
|
||||
)
|
||||
}
|
||||
|
||||
enum Privacy {
|
||||
static let title: LocalizedStringKey = "app_info.privacy.title"
|
||||
static let noTracking = AppInfoFeatureInfo(
|
||||
@@ -129,6 +143,11 @@ struct AppInfoView: View {
|
||||
.themedSheetBackground()
|
||||
}
|
||||
.frame(width: 600, height: 700)
|
||||
.sheet(isPresented: $showTopology) {
|
||||
if let topologyProvider {
|
||||
MeshTopologyView(provider: topologyProvider)
|
||||
}
|
||||
}
|
||||
#else
|
||||
NavigationView {
|
||||
ScrollView {
|
||||
@@ -143,6 +162,11 @@ struct AppInfoView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showTopology) {
|
||||
if let topologyProvider {
|
||||
MeshTopologyView(provider: topologyProvider)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -199,6 +223,27 @@ struct AppInfoView: View {
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
|
||||
// Network diagnostics
|
||||
if topologyProvider != nil {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
SectionHeader(Strings.Network.title)
|
||||
|
||||
Button {
|
||||
showTopology = true
|
||||
} label: {
|
||||
HStack(spacing: 0) {
|
||||
FeatureRow(info: Strings.Network.topology)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint(Text("app_info.network.topology.hint"))
|
||||
}
|
||||
}
|
||||
|
||||
// Features
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
SectionHeader(Strings.Features.title)
|
||||
|
||||
@@ -149,7 +149,7 @@ struct ContentView: View {
|
||||
#endif
|
||||
}
|
||||
.sheet(isPresented: $appChromeModel.isAppInfoPresented) {
|
||||
AppInfoView()
|
||||
AppInfoView(topologyProvider: { appChromeModel.meshTopologyDisplayModel() })
|
||||
}
|
||||
.sheet(isPresented: Binding(
|
||||
get: { appChromeModel.showingFingerprintFor != nil && !showSidebar && selectedPrivatePeerID == nil },
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
//
|
||||
// MeshTopologyView.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Display model for the mesh topology map: nodes are known mesh peers,
|
||||
/// edges are gossiped `directNeighbors` claims. Built on the main actor from
|
||||
/// a `MeshTopologySnapshot` plus the current nickname table.
|
||||
struct MeshTopologyDisplayModel {
|
||||
struct Node: Identifiable, Equatable {
|
||||
let id: String
|
||||
let label: String
|
||||
let isSelf: Bool
|
||||
}
|
||||
|
||||
let nodes: [Node]
|
||||
/// Pairs of `Node.id`; every id is present in `nodes`.
|
||||
let edges: [(String, String)]
|
||||
|
||||
static let empty = MeshTopologyDisplayModel(nodes: [], edges: [])
|
||||
}
|
||||
|
||||
/// Minimal diagnostics sheet: the mesh graph on a circular layout (self in
|
||||
/// the center), drawn with Canvas so it stays cheap at any peer count.
|
||||
struct MeshTopologyView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.appTheme) private var appTheme
|
||||
@ThemedPalette private var palette
|
||||
|
||||
/// Fetches a fresh model; called on appear and on manual refresh.
|
||||
let provider: @MainActor () -> MeshTopologyDisplayModel
|
||||
@State private var model: MeshTopologyDisplayModel = .empty
|
||||
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("topology.title")
|
||||
.bitchatFont(size: 16, weight: .bold)
|
||||
.foregroundColor(palette.primary)
|
||||
Spacer()
|
||||
refreshButton
|
||||
Button("app_info.done") {
|
||||
dismiss()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(palette.primary)
|
||||
}
|
||||
.padding()
|
||||
.themedSurface(opacity: 0.95)
|
||||
|
||||
content
|
||||
}
|
||||
.frame(width: 500, height: 520)
|
||||
.themedSheetBackground()
|
||||
#else
|
||||
NavigationView {
|
||||
content
|
||||
.themedSheetBackground()
|
||||
.navigationTitle(Text("topology.title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
refreshButton
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
SheetCloseButton { dismiss() }
|
||||
.foregroundColor(palette.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var refreshButton: some View {
|
||||
Button {
|
||||
model = provider()
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(palette.primary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("topology.refresh"))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
VStack(spacing: 12) {
|
||||
if model.nodes.count <= 1 {
|
||||
Spacer()
|
||||
Text("topology.empty")
|
||||
.bitchatFont(size: 14)
|
||||
.foregroundColor(palette.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
Spacer()
|
||||
} else {
|
||||
graphCanvas
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
|
||||
VStack(spacing: 4) {
|
||||
Text(summaryText)
|
||||
.bitchatFont(size: 13, weight: .semibold)
|
||||
.foregroundColor(palette.primary)
|
||||
Text("topology.caption")
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(palette.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
.onAppear { model = provider() }
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(Text(summaryText))
|
||||
}
|
||||
|
||||
private var summaryText: String {
|
||||
String(
|
||||
format: String(
|
||||
localized: "topology.summary",
|
||||
comment: "Topology map summary: number of peers and links"
|
||||
),
|
||||
locale: .current,
|
||||
model.nodes.count,
|
||||
model.edges.count
|
||||
)
|
||||
}
|
||||
|
||||
private var graphCanvas: some View {
|
||||
Canvas { context, size in
|
||||
let positions = Self.layout(nodes: model.nodes, in: size)
|
||||
let fontDesign = appTheme.bodyFontDesign
|
||||
|
||||
// Edges first so nodes draw on top.
|
||||
for (fromID, toID) in model.edges {
|
||||
guard let from = positions[fromID], let to = positions[toID] else { continue }
|
||||
var path = Path()
|
||||
path.move(to: from)
|
||||
path.addLine(to: to)
|
||||
context.stroke(path, with: .color(palette.secondary.opacity(0.45)), lineWidth: 1)
|
||||
}
|
||||
|
||||
for node in model.nodes {
|
||||
guard let center = positions[node.id] else { continue }
|
||||
let radius: CGFloat = node.isSelf ? 7 : 5
|
||||
let dot = Path(ellipseIn: CGRect(
|
||||
x: center.x - radius,
|
||||
y: center.y - radius,
|
||||
width: radius * 2,
|
||||
height: radius * 2
|
||||
))
|
||||
context.fill(dot, with: .color(node.isSelf ? palette.accent : palette.primary))
|
||||
if node.isSelf {
|
||||
let ring = Path(ellipseIn: CGRect(
|
||||
x: center.x - radius - 3,
|
||||
y: center.y - radius - 3,
|
||||
width: (radius + 3) * 2,
|
||||
height: (radius + 3) * 2
|
||||
))
|
||||
context.stroke(ring, with: .color(palette.accent.opacity(0.6)), lineWidth: 1)
|
||||
}
|
||||
context.draw(
|
||||
Text(node.label)
|
||||
.font(.system(size: 10, design: fontDesign))
|
||||
.foregroundColor(node.isSelf ? palette.accent : palette.secondary),
|
||||
at: CGPoint(x: center.x, y: center.y + radius + 4),
|
||||
anchor: .top
|
||||
)
|
||||
}
|
||||
}
|
||||
.accessibilityHidden(true) // The combined summary label narrates the graph.
|
||||
}
|
||||
|
||||
/// Circular layout: self in the center, everyone else evenly spaced on a
|
||||
/// ring. Deterministic (nodes arrive sorted), so refreshes don't shuffle.
|
||||
static func layout(nodes: [MeshTopologyDisplayModel.Node], in size: CGSize) -> [String: CGPoint] {
|
||||
let center = CGPoint(x: size.width / 2, y: size.height / 2)
|
||||
// Leave room for the label row under each ring node.
|
||||
let radius = max(20, min(size.width, size.height) / 2 - 36)
|
||||
var positions: [String: CGPoint] = [:]
|
||||
|
||||
let ringNodes = nodes.filter { !$0.isSelf }
|
||||
for node in nodes where node.isSelf {
|
||||
positions[node.id] = center
|
||||
}
|
||||
for (index, node) in ringNodes.enumerated() {
|
||||
let angle = (2 * CGFloat.pi * CGFloat(index)) / CGFloat(max(1, ringNodes.count)) - CGFloat.pi / 2
|
||||
positions[node.id] = CGPoint(
|
||||
x: center.x + radius * cos(angle),
|
||||
y: center.y + radius * sin(angle)
|
||||
)
|
||||
}
|
||||
return positions
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Topology") {
|
||||
MeshTopologyView(provider: {
|
||||
MeshTopologyDisplayModel(
|
||||
nodes: [
|
||||
.init(id: "self", label: "me", isSelf: true),
|
||||
.init(id: "a", label: "alice", isSelf: false),
|
||||
.init(id: "b", label: "bob", isSelf: false),
|
||||
.init(id: "c", label: "carol", isSelf: false)
|
||||
],
|
||||
edges: [("self", "a"), ("a", "b"), ("self", "c")]
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -602,6 +602,8 @@ private final class MockCommandContextProvider: CommandContextProvider {
|
||||
private(set) var sentPublicRawMessages: [String] = []
|
||||
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)] = []
|
||||
|
||||
@@ -657,6 +659,18 @@ private final class MockCommandContextProvider: CommandContextProvider {
|
||||
publicSystemMessages.append(content)
|
||||
}
|
||||
|
||||
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) {
|
||||
toggledFavorites.append(peerID)
|
||||
}
|
||||
|
||||
@@ -196,6 +196,27 @@ final class MockTransport: Transport {
|
||||
return courierSendResult
|
||||
}
|
||||
|
||||
// MARK: - Mesh Diagnostics
|
||||
|
||||
private(set) var sentMeshPings: [PeerID] = []
|
||||
var meshPingResult: MeshPingResult?
|
||||
var meshPaths: [PeerID: [PeerID]] = [:]
|
||||
var meshTopologySnapshot: MeshTopologySnapshot?
|
||||
|
||||
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) {
|
||||
sentMeshPings.append(peerID)
|
||||
let result = meshPingResult
|
||||
Task { @MainActor in completion(result) }
|
||||
}
|
||||
|
||||
func computeMeshPath(to peerID: PeerID) -> [PeerID]? {
|
||||
meshPaths[peerID]
|
||||
}
|
||||
|
||||
func currentMeshTopology() -> MeshTopologySnapshot? {
|
||||
meshTopologySnapshot
|
||||
}
|
||||
|
||||
// MARK: - Test Helpers
|
||||
|
||||
/// Clears all recorded method calls for fresh assertions
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
//
|
||||
// MeshDiagnosticsTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for /ping and /trace command handling and the topology snapshot
|
||||
// types backing the mesh topology map.
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
@Suite(.serialized)
|
||||
struct MeshDiagnosticsTests {
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
@MainActor
|
||||
private func makeProcessor(
|
||||
context: DiagnosticsMockContext,
|
||||
transport: MockTransport
|
||||
) -> CommandProcessor {
|
||||
CommandProcessor(
|
||||
contextProvider: context,
|
||||
meshService: transport,
|
||||
identityManager: MockIdentityManager(MockKeychain())
|
||||
)
|
||||
}
|
||||
|
||||
/// Waits for the async ping completion (MainActor hop) to land.
|
||||
@MainActor
|
||||
private func waitForCommandOutput(_ context: DiagnosticsMockContext) async {
|
||||
for _ in 0..<100 {
|
||||
if !context.commandOutputs.isEmpty { return }
|
||||
await Task.yield()
|
||||
try? await Task.sleep(nanoseconds: 5_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - /ping
|
||||
|
||||
@MainActor
|
||||
@Test func pingWithoutArgumentShowsUsage() {
|
||||
let context = DiagnosticsMockContext()
|
||||
let processor = makeProcessor(context: context, transport: MockTransport())
|
||||
let result = processor.process("/ping")
|
||||
switch result {
|
||||
case .error(let message):
|
||||
#expect(message == "usage: /ping <nickname>")
|
||||
default:
|
||||
Issue.record("Expected usage error")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func pingUnknownPeerFails() {
|
||||
let context = DiagnosticsMockContext()
|
||||
let processor = makeProcessor(context: context, transport: MockTransport())
|
||||
let result = processor.process("/ping @ghost")
|
||||
switch result {
|
||||
case .error(let message):
|
||||
#expect(message == "cannot ping ghost: not found on mesh")
|
||||
default:
|
||||
Issue.record("Expected error result")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func pingGeoDMPeerIsRejected() {
|
||||
let context = DiagnosticsMockContext()
|
||||
context.nicknameToPeerID["alice"] = PeerID(nostr_: "aabbccddeeff00112233445566778899")
|
||||
let processor = makeProcessor(context: context, transport: MockTransport())
|
||||
let result = processor.process("/ping @alice")
|
||||
switch result {
|
||||
case .error(let message):
|
||||
#expect(message == "cannot ping alice: not found on mesh")
|
||||
default:
|
||||
Issue.record("Expected error for geo peer")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func pingSuccessReportsRttAndHops() async {
|
||||
let context = DiagnosticsMockContext()
|
||||
let peerID = PeerID(str: "abcd1234abcd1234")
|
||||
context.nicknameToPeerID["alice"] = peerID
|
||||
let transport = MockTransport()
|
||||
transport.meshPingResult = MeshPingResult(rttMs: 42, hops: 2)
|
||||
let processor = makeProcessor(context: context, transport: transport)
|
||||
|
||||
let result = processor.process("/ping @alice")
|
||||
switch result {
|
||||
case .success(let message):
|
||||
#expect(message == "pinging alice…")
|
||||
default:
|
||||
Issue.record("Expected immediate 'pinging' feedback")
|
||||
}
|
||||
#expect(transport.sentMeshPings == [peerID])
|
||||
|
||||
await waitForCommandOutput(context)
|
||||
#expect(context.commandOutputs == ["pong from alice: 42 ms · 2 hops"])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func pingDirectPeerReportsSingleHop() async {
|
||||
let context = DiagnosticsMockContext()
|
||||
context.nicknameToPeerID["alice"] = PeerID(str: "abcd1234abcd1234")
|
||||
let transport = MockTransport()
|
||||
transport.meshPingResult = MeshPingResult(rttMs: 8, hops: 1)
|
||||
let processor = makeProcessor(context: context, transport: transport)
|
||||
|
||||
_ = processor.process("/ping alice")
|
||||
await waitForCommandOutput(context)
|
||||
#expect(context.commandOutputs == ["pong from alice: 8 ms · direct (1 hop)"])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func pingTimeoutReportsNoReply() async {
|
||||
let context = DiagnosticsMockContext()
|
||||
context.nicknameToPeerID["alice"] = PeerID(str: "abcd1234abcd1234")
|
||||
let transport = MockTransport()
|
||||
transport.meshPingResult = nil
|
||||
let processor = makeProcessor(context: context, transport: transport)
|
||||
|
||||
_ = processor.process("/ping @alice")
|
||||
await waitForCommandOutput(context)
|
||||
#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
|
||||
@Test func traceDirectPeerShowsOneHop() {
|
||||
let context = DiagnosticsMockContext()
|
||||
let bob = PeerID(str: "b0b0b0b0b0b0b0b0")
|
||||
context.nicknameToPeerID["bob"] = bob
|
||||
let transport = MockTransport()
|
||||
transport.meshPaths[bob] = []
|
||||
let processor = makeProcessor(context: context, transport: transport)
|
||||
|
||||
let result = processor.process("/trace @bob")
|
||||
switch result {
|
||||
case .success(let message):
|
||||
#expect(message == "estimated path: you → bob (1 hop)")
|
||||
default:
|
||||
Issue.record("Expected success result")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func traceMultiHopUsesNicknamesWithShortIDFallback() {
|
||||
let context = DiagnosticsMockContext()
|
||||
let bob = PeerID(str: "b0b0b0b0b0b0b0b0")
|
||||
let alice = PeerID(str: "a11cea11cea11cea")
|
||||
let unknown = PeerID(str: "dead00beef001234")
|
||||
context.nicknameToPeerID["bob"] = bob
|
||||
let transport = MockTransport()
|
||||
transport.peerNicknames = [alice: "alice"]
|
||||
transport.meshPaths[bob] = [alice, unknown]
|
||||
let processor = makeProcessor(context: context, transport: transport)
|
||||
|
||||
let result = processor.process("/trace bob")
|
||||
switch result {
|
||||
case .success(let message):
|
||||
#expect(message == "estimated path: you → alice → dead00be… → bob (3 hops)")
|
||||
default:
|
||||
Issue.record("Expected success result")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func traceWithoutPathReportsNoKnownPath() {
|
||||
let context = DiagnosticsMockContext()
|
||||
context.nicknameToPeerID["bob"] = PeerID(str: "b0b0b0b0b0b0b0b0")
|
||||
let processor = makeProcessor(context: context, transport: MockTransport())
|
||||
|
||||
let result = processor.process("/trace @bob")
|
||||
switch result {
|
||||
case .success(let message):
|
||||
#expect(message == "no known path to bob")
|
||||
default:
|
||||
Issue.record("Expected success result")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Topology snapshot types
|
||||
|
||||
@Test func topologyEdgeNormalizesEndpointOrder() {
|
||||
let a = PeerID(str: "aaaa000000000000")
|
||||
let b = PeerID(str: "bbbb000000000000")
|
||||
#expect(MeshTopologyEdge(a, b) == MeshTopologyEdge(b, a))
|
||||
#expect(Set([MeshTopologyEdge(a, b), MeshTopologyEdge(b, a)]).count == 1)
|
||||
}
|
||||
|
||||
@Test func topologyLayoutPlacesSelfInCenter() {
|
||||
let nodes: [MeshTopologyDisplayModel.Node] = [
|
||||
.init(id: "self", label: "me", isSelf: true),
|
||||
.init(id: "a", label: "alice", isSelf: false),
|
||||
.init(id: "b", label: "bob", isSelf: false)
|
||||
]
|
||||
let size = CGSize(width: 200, height: 200)
|
||||
let positions = MeshTopologyView.layout(nodes: nodes, in: size)
|
||||
|
||||
#expect(positions["self"] == CGPoint(x: 100, y: 100))
|
||||
#expect(positions.count == 3)
|
||||
// Ring nodes sit on the same radius around the center.
|
||||
let radiusA = hypot((positions["a"]?.x ?? 0) - 100, (positions["a"]?.y ?? 0) - 100)
|
||||
let radiusB = hypot((positions["b"]?.x ?? 0) - 100, (positions["b"]?.y ?? 0) - 100)
|
||||
#expect(abs(radiusA - radiusB) < 0.001)
|
||||
#expect(radiusA > 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal CommandContextProvider for diagnostics tests; records deferred
|
||||
/// command output so async /ping results can be asserted.
|
||||
@MainActor
|
||||
private final class DiagnosticsMockContext: CommandContextProvider {
|
||||
var nickname: String = "tester"
|
||||
var activeChannel: ChannelID = .mesh
|
||||
var selectedPrivateChatPeer: PeerID?
|
||||
var blockedUsers: Set<String> = []
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychain())
|
||||
|
||||
var nicknameToPeerID: [String: PeerID] = [:]
|
||||
private(set) var commandOutputs: [String] = []
|
||||
private(set) var commandOutputDestinations: [CommandOutputDestination] = []
|
||||
|
||||
func getPeerIDForNickname(_ nickname: String) -> PeerID? {
|
||||
nicknameToPeerID[nickname]
|
||||
}
|
||||
|
||||
func getVisibleGeoParticipants() -> [CommandGeoParticipant] { [] }
|
||||
func nostrPubkeyForDisplayName(_ displayName: String) -> String? { nil }
|
||||
func startPrivateChat(with peerID: PeerID) {}
|
||||
func sendPrivateMessage(_ content: String, to peerID: PeerID) {}
|
||||
func clearCurrentPublicTimeline() {}
|
||||
func clearPrivateChat(_ peerID: PeerID) {}
|
||||
func sendPublicRaw(_ content: String) {}
|
||||
func sendPublicMessage(_ content: String) {}
|
||||
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {}
|
||||
func addPublicSystemMessage(_ content: String) {}
|
||||
func toggleFavorite(peerID: PeerID) {}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//
|
||||
// MeshPingPayload.swift
|
||||
// BitFoundation
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import struct Foundation.Data
|
||||
|
||||
/// Wire payload shared by the `ping` (0x26) and `pong` (0x27) message types.
|
||||
///
|
||||
/// Layout (9 bytes):
|
||||
/// - 8 bytes: random nonce (a pong echoes the nonce of the ping it answers)
|
||||
/// - 1 byte: origin TTL — the TTL the packet was launched with, so the
|
||||
/// receiver can compute the hop count as `originTTL - receivedTTL`.
|
||||
///
|
||||
/// Both directions are unencrypted and unsigned: the payload carries no
|
||||
/// private data, and the unguessable nonce already binds a pong to a probe
|
||||
/// the local device actually sent.
|
||||
public struct MeshPingPayload: Equatable {
|
||||
public static let nonceLength = 8
|
||||
private static let encodedLength = nonceLength + 1
|
||||
|
||||
public let nonce: Data
|
||||
public let originTTL: UInt8
|
||||
|
||||
public init?(nonce: Data, originTTL: UInt8) {
|
||||
guard nonce.count == Self.nonceLength else { return nil }
|
||||
self.nonce = nonce
|
||||
self.originTTL = originTTL
|
||||
}
|
||||
|
||||
public func encode() -> Data {
|
||||
var data = Data(capacity: Self.encodedLength)
|
||||
data.append(nonce)
|
||||
data.append(originTTL)
|
||||
return data
|
||||
}
|
||||
|
||||
/// Accepts payloads with trailing bytes so future revisions can extend
|
||||
/// the format without breaking older clients.
|
||||
public static func decode(_ data: Data) -> MeshPingPayload? {
|
||||
guard data.count >= encodedLength else { return nil }
|
||||
let nonce = Data(data.prefix(nonceLength))
|
||||
let originTTL = data[data.index(data.startIndex, offsetBy: nonceLength)]
|
||||
return MeshPingPayload(nonce: nonce, originTTL: originTTL)
|
||||
}
|
||||
|
||||
/// Number of links a packet crossed, derived from TTL decrements plus the
|
||||
/// final delivery link (a directly connected peer is 1 hop away).
|
||||
/// Returns nil when the TTLs are inconsistent (received above origin).
|
||||
public static func hopCount(originTTL: UInt8, receivedTTL: UInt8) -> Int? {
|
||||
guard originTTL >= receivedTTL else { return nil }
|
||||
return Int(originTTL - receivedTTL) + 1
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,10 @@ public enum MessageType: UInt8 {
|
||||
case fileTransfer = 0x22 // Binary file/audio/image payloads
|
||||
case boardPost = 0x23 // Signed geohash bulletin-board post or tombstone
|
||||
|
||||
// Mesh diagnostics
|
||||
case ping = 0x26 // Directed echo request (nonce + origin TTL)
|
||||
case pong = 0x27 // Directed echo reply (echoed nonce + origin TTL)
|
||||
|
||||
// Gateway mode: signed Nostr event ferried between a mesh-only peer and
|
||||
// an internet gateway peer.
|
||||
case nostrCarrier = 0x28
|
||||
@@ -42,6 +46,8 @@ public enum MessageType: UInt8 {
|
||||
case .fragment: return "fragment"
|
||||
case .fileTransfer: return "fileTransfer"
|
||||
case .boardPost: return "boardPost"
|
||||
case .ping: return "ping"
|
||||
case .pong: return "pong"
|
||||
case .nostrCarrier: return "nostrCarrier"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//
|
||||
// MeshPingPayloadTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import BitFoundation
|
||||
|
||||
struct MeshPingPayloadTests {
|
||||
|
||||
@Test func encodeDecodeRoundTrip() throws {
|
||||
let nonce = Data([0x01, 0x02, 0x03, 0x04, 0xAA, 0xBB, 0xCC, 0xFF])
|
||||
let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 7))
|
||||
|
||||
let encoded = payload.encode()
|
||||
#expect(encoded.count == 9)
|
||||
#expect(encoded.prefix(8) == nonce)
|
||||
#expect(encoded.last == 7)
|
||||
|
||||
let decoded = try #require(MeshPingPayload.decode(encoded))
|
||||
#expect(decoded == payload)
|
||||
}
|
||||
|
||||
@Test func decodeToleratesTrailingBytes() throws {
|
||||
let nonce = Data(repeating: 0x42, count: 8)
|
||||
let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 3))
|
||||
var extended = payload.encode()
|
||||
extended.append(contentsOf: [0xDE, 0xAD])
|
||||
|
||||
let decoded = try #require(MeshPingPayload.decode(extended))
|
||||
#expect(decoded == payload)
|
||||
}
|
||||
|
||||
@Test func decodeRespectsSliceIndices() throws {
|
||||
// Data slices keep their parent's indices; decoding must not assume
|
||||
// startIndex == 0.
|
||||
let nonce = Data(repeating: 0x11, count: 8)
|
||||
let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 5))
|
||||
let framed = Data([0x00, 0x00]) + payload.encode()
|
||||
let slice = framed.dropFirst(2)
|
||||
|
||||
let decoded = try #require(MeshPingPayload.decode(slice))
|
||||
#expect(decoded == payload)
|
||||
}
|
||||
|
||||
@Test func rejectsTruncatedPayload() {
|
||||
#expect(MeshPingPayload.decode(Data(repeating: 0x01, count: 8)) == nil)
|
||||
#expect(MeshPingPayload.decode(Data()) == nil)
|
||||
}
|
||||
|
||||
@Test func rejectsWrongNonceLength() {
|
||||
#expect(MeshPingPayload(nonce: Data(repeating: 0, count: 7), originTTL: 7) == nil)
|
||||
#expect(MeshPingPayload(nonce: Data(repeating: 0, count: 9), originTTL: 7) == nil)
|
||||
}
|
||||
|
||||
@Test func hopCountMath() {
|
||||
// Direct link: no TTL decrement, one hop.
|
||||
#expect(MeshPingPayload.hopCount(originTTL: 7, receivedTTL: 7) == 1)
|
||||
// One relay in between: two hops.
|
||||
#expect(MeshPingPayload.hopCount(originTTL: 7, receivedTTL: 6) == 2)
|
||||
// Full TTL consumed.
|
||||
#expect(MeshPingPayload.hopCount(originTTL: 7, receivedTTL: 1) == 7)
|
||||
// Inconsistent TTLs (received above origin) are rejected.
|
||||
#expect(MeshPingPayload.hopCount(originTTL: 3, receivedTTL: 7) == nil)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user