fix: complete direct-link routing rollback

This commit is contained in:
callebtc 2026-07-27 18:47:17 +02:00
parent 6dff0844bb
commit 62dd3ca90e
5 changed files with 261 additions and 0 deletions

View File

@ -0,0 +1,26 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.RoutedPacket
/**
* Describes transport reachability learned from an already-validated ANNOUNCE.
*
* This is deliberately only a routing observation. Noise authenticates the peer independently and
* must not be restarted merely to associate the current transport link with that peer.
*/
internal object DirectLinkAnnouncementPolicy {
data class Observation(
val peerID: String,
val relayAddress: String,
val ingressLinkID: String
)
fun observationFor(routed: RoutedPacket, maxTtl: UByte): Observation? {
if (routed.packet.ttl != maxTtl) return null
return Observation(
peerID = routed.peerID ?: return null,
relayAddress = routed.relayAddress ?: return null,
ingressLinkID = routed.ingressLinkID ?: return null
)
}
}

View File

@ -0,0 +1,22 @@
package com.bitchat.android.wifiaware
/** Resolves a packet to the exact still-active ingress link that delivered it. */
internal object IngressLinkPolicy {
data class Link<T : Any>(
val relayAddress: String,
val transport: T
)
fun <T : Any> resolve(
ingressLinkID: String?,
relayAddress: String?,
links: Map<String, Link<T>>,
currentTransportForRelay: (String) -> T?
): Link<T>? {
val linkID = ingressLinkID ?: return null
val relayAddress = relayAddress ?: return null
val link = links[linkID] ?: return null
if (link.relayAddress != relayAddress) return null
return link.takeIf { currentTransportForRelay(relayAddress) === it.transport }
}
}

View File

@ -0,0 +1,85 @@
package com.bitchat.android.mesh
import android.bluetooth.BluetoothDevice
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
class BluetoothConnectionTrackerLinkObservationTest {
private val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob())
private val tracker = BluetoothConnectionTracker(scope, mock())
@After
fun tearDown() {
scope.cancel()
}
@Test
fun `stale connection callbacks cannot mutate or remove replacement link`() {
val address = "AA:BB:CC:DD:EE:FF"
val device = mock<BluetoothDevice>()
whenever(device.address).thenReturn(address)
tracker.addDeviceConnection(
address,
BluetoothConnectionTracker.DeviceConnection(device = device, linkID = "link-a")
)
tracker.addDeviceConnection(
address,
BluetoothConnectionTracker.DeviceConnection(device = device, linkID = "link-b")
)
assertFalse(
tracker.updateDeviceConnectionIfCurrent(address, "link-a") {
it.copy(rssi = -10)
}
)
assertFalse(tracker.cleanupDeviceConnectionIfCurrent(address, "link-a"))
assertEquals("link-b", tracker.getCurrentLinkID(address))
assertTrue(tracker.observePeerIfCurrent(address, "link-b", "0011223344556677"))
assertEquals("0011223344556677", tracker.addressPeerMap[address])
assertSame(device, tracker.getDeviceConnection(address)?.device)
}
@Test
fun `one peer can remain directly observed over multiple current links`() {
val firstAddress = "AA:BB:CC:DD:EE:01"
val secondAddress = "AA:BB:CC:DD:EE:02"
val firstDevice = mock<BluetoothDevice>()
val secondDevice = mock<BluetoothDevice>()
whenever(firstDevice.address).thenReturn(firstAddress)
whenever(secondDevice.address).thenReturn(secondAddress)
tracker.addDeviceConnection(
firstAddress,
BluetoothConnectionTracker.DeviceConnection(device = firstDevice, linkID = "link-a")
)
tracker.addDeviceConnection(
secondAddress,
BluetoothConnectionTracker.DeviceConnection(device = secondDevice, linkID = "link-b")
)
assertTrue(tracker.observePeerIfCurrent(firstAddress, "link-a", PEER_ID))
assertTrue(tracker.observePeerIfCurrent(secondAddress, "link-b", PEER_ID))
assertTrue(tracker.observePeerIfCurrent(secondAddress, "link-b", PEER_ID))
assertEquals(2, tracker.addressPeerMap.values.count { it == PEER_ID })
assertTrue(tracker.cleanupDeviceConnectionIfCurrent(firstAddress, "link-a"))
assertEquals(PEER_ID, tracker.addressPeerMap[secondAddress])
assertTrue(tracker.addressPeerMap.containsValue(PEER_ID))
}
private companion object {
const val PEER_ID = "0011223344556677"
}
}

View File

@ -0,0 +1,64 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class DirectLinkAnnouncementPolicyTest {
@Test
fun `accepted max ttl announce is a direct routing observation`() {
val routed = announce(ttl = MAX_TTL)
assertEquals(
DirectLinkAnnouncementPolicy.Observation(PEER_ID, RELAY_ADDRESS, LINK_ID),
DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL)
)
}
@Test
fun `relayed announce is not a direct routing observation`() {
assertNull(
DirectLinkAnnouncementPolicy.observationFor(
announce(ttl = (MAX_TTL - 1u).toUByte()),
MAX_TTL
)
)
}
@Test
fun `repeated announce remains the same observation without transport authentication state`() {
val routed = announce(ttl = MAX_TTL)
val first = DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL)
val second = DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL)
assertEquals(first, second)
}
private fun announce(ttl: UByte) = RoutedPacket(
packet = BitchatPacket(
version = 1u,
type = MessageType.ANNOUNCE.value,
senderID = PEER_ID.hexToBytes(),
timestamp = 1u,
payload = byteArrayOf(1),
ttl = ttl
),
peerID = PEER_ID,
relayAddress = RELAY_ADDRESS,
ingressLinkID = LINK_ID
)
private fun String.hexToBytes(): ByteArray =
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
private companion object {
const val PEER_ID = "0011223344556677"
const val RELAY_ADDRESS = "transport-neighbor"
const val LINK_ID = "current-link"
val MAX_TTL: UByte = 7u
}
}

View File

@ -0,0 +1,64 @@
package com.bitchat.android.wifiaware
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Test
class IngressLinkPolicyTest {
@Test
fun `observation resolves only the exact ingress link`() {
val attackerSocket = Any()
val victimSocket = Any()
val links = mapOf(
"attacker-link" to IngressLinkPolicy.Link("provisional-attacker", attackerSocket),
"victim-link" to IngressLinkPolicy.Link("provisional-victim", victimSocket)
)
val current = mapOf(
"provisional-attacker" to attackerSocket,
"provisional-victim" to victimSocket
)
val resolved = IngressLinkPolicy.resolve(
ingressLinkID = "victim-link",
relayAddress = "provisional-victim",
links = links,
currentTransportForRelay = current::get
)
assertSame(victimSocket, resolved?.transport)
}
@Test
fun `stale replaced or mismatched ingress links cannot be observed`() {
val completedSocket = Any()
val replacementSocket = Any()
val links = mapOf(
"completed-link" to IngressLinkPolicy.Link("provisional", completedSocket)
)
assertNull(
IngressLinkPolicy.resolve(
ingressLinkID = "missing-link",
relayAddress = "provisional",
links = links,
currentTransportForRelay = { completedSocket }
)
)
assertNull(
IngressLinkPolicy.resolve(
ingressLinkID = "completed-link",
relayAddress = "different-provisional",
links = links,
currentTransportForRelay = { completedSocket }
)
)
assertNull(
IngressLinkPolicy.resolve(
ingressLinkID = "completed-link",
relayAddress = "provisional",
links = links,
currentTransportForRelay = { replacementSocket }
)
)
}
}