From f7f69ea62137617f67a20f48bb5931a74b6681c3 Mon Sep 17 00:00:00 2001 From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:32:10 -0500 Subject: [PATCH 1/2] fix(node): reconcile stale identity replays after renumbering Reconcile node identities in memory when firmware 2.8 renumbering or delayed packets present the same validated public key under multiple node numbers. Use one atomic reducer state for the node map, local-node number, reverse ID index, public-key candidates, and session-retired identities so readers never observe mismatched indexes and nodes. Treat public keys learned from remote User packets as correlation hints, not as authority to delete or migrate persistent history. Durable number migration remains owned by trusted configuration installation and migrateNodeIdentity. Apply those trusted removals before cancelling old-number notifications without running side effects inside a retryable CAS transform. Retain validated old-key hints when trusted migration removes a known identity. Suppress delayed field packets, keyless or invalid User packets, same-key replays, and keys already represented elsewhere for the active database session. Keep a retired slot closed when trusted migration has no old-key evidence; permit different-key reuse only when a trusted hint exists and the incoming key is otherwise unrepresented. For ambiguous noncanonical renumbering, accept an incoming identity into an empty or generated-placeholder slot in memory so pre-2.8 and fixed-number peers cannot become session-locked. Preserve established conflicting identities and all placeholder telemetry. Because mesh public keys are broadcast and unauthenticated, this path deliberately queues no persistence, migration, deletion, or notification effect. When legitimate retired-number reuse is accepted, clear both the retired marker and old-key hint in the same successful transition. Select representatives deterministically, preserve conflicting different-key identities, and reset session retirement when the node database is cleared or reloaded. Centralize stored-key precedence so the primary Node key consistently wins over the embedded User key. Keep diagnostics privacy-safe by logging only a short SHA-256 fingerprint; never expose raw key prefixes, user identity fields, device identifiers, or transport addresses. --- .../data/datasource/NodeInfoReadDataSource.kt | 7 + .../SwitchingNodeInfoReadDataSource.kt | 3 + .../data/manager/MeshConfigFlowManagerImpl.kt | 2 +- .../core/data/manager/NodeManagerImpl.kt | 824 +++++++++++++++--- .../data/repository/NodeRepositoryImpl.kt | 3 + .../core/database/dao/NodeInfoDao.kt | 22 + .../kotlin/org/meshtastic/core/model/Node.kt | 5 +- .../meshtastic/core/repository/NodeManager.kt | 6 + .../core/repository/NodeRepository.kt | 7 + .../core/testing/FakeNodeRepository.kt | 2 + 10 files changed, 766 insertions(+), 115 deletions(-) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoReadDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoReadDataSource.kt index c9438ebbf6..ea62007689 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoReadDataSource.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoReadDataSource.kt @@ -37,4 +37,11 @@ interface NodeInfoReadDataSource { suspend fun getNodesOlderThan(lastHeard: Int): List suspend fun getUnknownNodes(): List + + /** + * One-shot snapshot of every node row in the currently selected database. Bypasses the process-wide + * [nodeDBbyNumFlow] cache so callers see the live DB state at invocation time rather than a `stateIn` value that + * may belong to a previous transport. + */ + suspend fun getNodeDbSnapshot(): Map } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt index 1182888777..7fdd69c2b7 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt @@ -55,4 +55,7 @@ class SwitchingNodeInfoReadDataSource(private val dbManager: DatabaseProvider) : override suspend fun getUnknownNodes(): List = dbManager.withReadDb { it.nodeInfoDao().getUnknownNodes() } + + override suspend fun getNodeDbSnapshot(): Map = + dbManager.withReadDb { it.nodeInfoDao().nodeDBbyNumSnapshot() } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt index e4f8e752ba..7640e2285a 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt @@ -296,7 +296,7 @@ class MeshConfigFlowManagerImpl( if (!isActiveSession(session)) return if (removedNums.isNotEmpty()) { Logger.i { "Config install migrated ${removedNums.size} stale node identit(y/ies)" } - removedNums.forEach(nodeManager::removeByNodenum) + nodeManager.applyTrustedIdentityMigrations(removedNums) } val published = diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt index 78a9b09f57..da7e33cfa6 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt @@ -20,17 +20,19 @@ import co.touchlab.kermit.Logger import kotlinx.atomicfu.atomic import kotlinx.atomicfu.update import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.PersistentSet import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okio.ByteString import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.util.clampTimestampToNow +import org.meshtastic.core.common.util.crc32 import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node @@ -57,8 +59,29 @@ import kotlinx.coroutines.flow.update as updateStateFlow import org.meshtastic.proto.NodeInfo as ProtoNodeInfo import org.meshtastic.proto.Position as ProtoPosition +/** + * Resolves a validated public-key correlation hint from a raw [ByteString]. Returns null unless the key is exactly + * [Node.PUBLIC_KEY_SIZE] bytes and is not [Node.ERROR_BYTE_STRING]. The hint is used only for in-memory correlation; it + * is not a trusted hardware identity and never authorizes durable node migration or deletion. + */ +private fun resolveValidatedPublicKeyHint(key: ByteString?): ByteString? { + if (key == null || key.size != Node.PUBLIC_KEY_SIZE || key == Node.ERROR_BYTE_STRING) return null + return key +} + +/** Single source of truth for stored-node key precedence: the primary field wins over the embedded [User] field. */ +private fun nodePublicKeyHint(node: Node): ByteString? = + resolveValidatedPublicKeyHint(node.publicKey) ?: resolveValidatedPublicKeyHint(node.user.public_key) + +/** Short one-way diagnostic value that cannot disclose a raw public-key prefix. */ +private const val KEY_FINGERPRINT_HEX_LENGTH = 8 + +internal fun publicKeyLogFingerprint(key: ByteString): String = key.sha256().hex().take(KEY_FINGERPRINT_HEX_LENGTH) + +private val DEFAULT_NODE_NAME_REGEX = Regex("^Meshtastic [0-9a-fA-F]{4}$") + /** Implementation of [NodeManager] that maintains an in-memory database of the mesh. */ -@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod") +@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod", "LargeClass") @Single(binds = [NodeManager::class, NodeIdLookup::class]) class NodeManagerImpl( private val nodeRepository: NodeRepository, @@ -67,59 +90,172 @@ class NodeManagerImpl( @Named("ServiceScope") private val scope: CoroutineScope, ) : NodeManager { - // Fixed stripes bound mutex lifetime while preserving same-node ordering; hash collisions only reduce parallelism. + // Fixed stripes bound mutex lifetime while preserving same-node persistence ordering. Hash collisions only reduce + // parallelism; they never weaken ordering for a node. private val nodePersistenceLanes = List(NODE_PERSISTENCE_LANE_COUNT) { Mutex() } private fun persistenceLane(nodeNum: Int): Mutex = nodePersistenceLanes[(nodeNum.toLong() and Int.MAX_VALUE.toLong()).toInt() % nodePersistenceLanes.size] - // Two indices over the same node set: byNum is the canonical store (mesh-level identifier), byId is a secondary - // O(1) lookup for the user-facing hex string. Both are held in a single atomic ref so updates are observed - // consistently — concurrent readers never see an entry present in one index but not the other. - private data class NodeIndex( + /** + * Resolves a validated public-key correlation hint from a stored [Node], preferring [Node.publicKey] and falling + * back to [User.public_key] only when the primary field is not itself a valid hint. + */ + internal fun resolveNodePublicKeyHint(node: Node): ByteString? = nodePublicKeyHint(node) + + // byNum is the canonical in-memory store. Candidate-number reverse sets make normal put/remove and keyed packet + // handling proportional to the affected ID/key candidates rather than the entire mesh. byId stores the selected + // deterministic representative for O(1) user-ID lookup. + internal data class NodeIndex( val byNum: PersistentMap = persistentMapOf(), val byId: PersistentMap = persistentMapOf(), + val candidateNumsById: PersistentMap> = persistentMapOf(), + val candidateNumsByPublicKey: PersistentMap> = persistentMapOf(), ) { - fun put(num: Int, node: Node): NodeIndex { + fun put(num: Int, node: Node, preferredNum: Int? = null): NodeIndex { val previous = byNum[num] - var nextById = byId - // If the user.id changed (e.g. firmware reassigned the hex id) drop the stale id entry. - if (previous != null && previous.user.id.isNotEmpty() && previous.user.id != node.user.id) { - nextById = nextById.removing(previous.user.id) + val nextByNum = byNum.putting(num, node) + var nextCandidatesById = candidateNumsById + var nextCandidatesByPublicKey = candidateNumsByPublicKey + + previous + ?.user + ?.id + ?.takeIf { it.isNotEmpty() } + ?.let { id -> nextCandidatesById = nextCandidatesById.removingCandidate(id, num) } + previous + ?.let { nodePublicKeyHint(it) } + ?.let { key -> nextCandidatesByPublicKey = nextCandidatesByPublicKey.removingCandidate(key, num) } + node.user.id + .takeIf { it.isNotEmpty() } + ?.let { id -> nextCandidatesById = nextCandidatesById.addingCandidate(id, num) } + nodePublicKeyHint(node)?.let { key -> + nextCandidatesByPublicKey = nextCandidatesByPublicKey.addingCandidate(key, num) } - if (node.user.id.isNotEmpty()) { - nextById = nextById.putting(node.user.id, node) + + var nextById = byId + val affectedIds = setOfNotNull(previous?.user?.id, node.user.id).filter { it.isNotEmpty() } + for (id in affectedIds) { + val representative = chooseRepresentativeNum(nextCandidatesById[id].orEmpty(), nextByNum, preferredNum) + nextById = + if (representative == null) { + nextById.removing(id) + } else { + nextById.putting(id, checkNotNull(nextByNum[representative])) + } } - return NodeIndex(byNum = byNum.putting(num, node), byId = nextById) + return NodeIndex(nextByNum, nextById, nextCandidatesById, nextCandidatesByPublicKey) } - fun remove(num: Int): NodeIndex { + /** + * Removes [num] from both indices. When the removed node's user ID was the [byId] representative and another + * surviving node shares that ID, [preferredNum] wins when present; otherwise deterministic ordering selects the + * replacement. + */ + fun remove(num: Int, preferredNum: Int? = null): NodeIndex { val previous = byNum[num] ?: return this - return NodeIndex( - byNum = byNum.removing(num), - byId = if (previous.user.id.isNotEmpty()) byId.removing(previous.user.id) else byId, - ) + val newByNum = byNum.removing(num) + var newCandidatesById = candidateNumsById + var newCandidatesByPublicKey = candidateNumsByPublicKey + previous.user.id + .takeIf { it.isNotEmpty() } + ?.let { id -> newCandidatesById = newCandidatesById.removingCandidate(id, num) } + nodePublicKeyHint(previous)?.let { key -> + newCandidatesByPublicKey = newCandidatesByPublicKey.removingCandidate(key, num) + } + var newById = byId + if (previous.user.id.isNotEmpty()) { + val survivor = + chooseRepresentativeNum(newCandidatesById[previous.user.id].orEmpty(), newByNum, preferredNum) + newById = + if (survivor != null) { + newById.putting(previous.user.id, checkNotNull(newByNum[survivor])) + } else { + newById.removing(previous.user.id) + } + } + return NodeIndex(newByNum, newById, newCandidatesById, newCandidatesByPublicKey) } companion object { + internal fun isDefaultIdentityPlaceholder(node: Node): Boolean = nodePublicKeyHint(node) == null && + node.user.hw_model == HardwareModel.UNSET && + node.user.id == NodeAddress.numToDefaultId(node.num) && + node.user.long_name.matches(DEFAULT_NODE_NAME_REGEX) + + /** + * Selects a deterministic representative number from [candidates] sharing one user ID. If [preferredNum] is + * present it wins; otherwise an established key/hardware identity wins, then the lower node number. + */ + internal fun chooseRepresentativeNum( + candidates: Set, + nodes: Map, + preferredNum: Int?, + ): Int? { + if (preferredNum != null && preferredNum in candidates) return preferredNum + return candidates.minWithOrNull( + compareByDescending { num -> + nodes[num]?.let { node -> + nodePublicKeyHint(node) != null || node.user.hw_model != HardwareModel.UNSET + } == true + } + .thenBy { it }, + ) + } + fun fromByNum(nodes: Map): NodeIndex { - var byNum = persistentMapOf() - var byId = persistentMapOf() - for ((num, node) in nodes) { - byNum = byNum.putting(num, node) - if (node.user.id.isNotEmpty()) byId = byId.putting(node.user.id, node) - } - return NodeIndex(byNum, byId) + var result = NodeIndex() + nodes.forEach { (num, node) -> result = result.put(num, node) } + return result + } + + private fun PersistentMap>.addingCandidate( + key: K, + num: Int, + ): PersistentMap> = putting(key, (this[key] ?: persistentSetOf()).adding(num)) + + private fun PersistentMap>.removingCandidate( + key: K, + num: Int, + ): PersistentMap> { + val remaining = this[key]?.removing(num) ?: return this + return if (remaining.isEmpty()) removing(key) else putting(key, remaining) } } } - private val nodeIndex = atomic(NodeIndex()) + private data class NodeState( + val index: NodeIndex = NodeIndex(), + val localNodeNum: Int? = null, + val retiredNodeNums: PersistentSet = persistentSetOf(), + /** + * Validated public-key hint for each retired number, captured before index removal. Used to suppress same-key + * replays even when no canonical candidate currently holds the key in the in-memory index. + */ + val retiredKeyHints: PersistentMap = persistentMapOf(), + /** + * Monotonic counter bumped on every live mutation to [index] or [localNodeNum]. Captured by [loadCachedNodeDB] + * before its suspending snapshot read so the commit step can detect concurrent live mutations and merge instead + * of overwriting them. + */ + val revision: Long = 0L, + ) { + fun isRetiredAbsent(nodeNum: Int): Boolean = nodeNum in retiredNodeNums && nodeNum !in index.byNum + } + + private val nodeState = atomic(NodeState()) + + /** + * Session generation, bumped by [clear]. Captured by [loadCachedNodeDB] before its suspending snapshot read so a + * load launched in one session can be discarded completely if [clear] started a new session before the load + * committed. + */ + private val sessionGeneration = atomic(0L) override val nodeDBbyNodeNum: Map - get() = nodeIndex.value.byNum + get() = nodeState.value.index.byNum - override fun getNodeById(id: String): Node? = nodeIndex.value.byId[id] + override fun getNodeById(id: String): Node? = nodeState.value.index.byId[id] override val isNodeDbReady = MutableStateFlow(false) override val allowNodeDbWrites = MutableStateFlow(false) @@ -135,6 +271,7 @@ class NodeManagerImpl( override val myNodeNum = MutableStateFlow(null) override fun setMyNodeNum(num: Int?) { + nodeState.update { it.copy(localNodeNum = num, revision = it.revision + 1) } myNodeNum.value = num } @@ -169,25 +306,72 @@ class NodeManagerImpl( companion object { private const val NODE_PERSISTENCE_LANE_COUNT = 64 - private const val TIME_MS_TO_S = 1000L + private const val GENERATED_NODE_NAME_SUFFIX_LENGTH = 4 } override fun loadCachedNodeDB() { - // NOTE: this method intentionally does NOT touch _connectionIdentity. The connection-session identity is - // only populated by a fresh MyNodeInfo handshake (see publishConnectionIdentity); restoring it from cache - // would re-introduce the stale-identity association bug across transport switches. + // NOTE: this method intentionally does NOT touch _connectionIdentity. The connection-session identity is only + // populated by a fresh MyNodeInfo handshake; restoring it from cache would re-introduce a stale-identity + // association across transport switches. scope.handledLaunch { - val nodes = nodeRepository.nodeDBbyNum.first() - nodeIndex.value = NodeIndex.fromByNum(nodes) - if (myNodeNum.value == null) { - myNodeNum.value = nodeRepository.myNodeInfo.value?.myNodeNum + // Capture the session generation and live revision BEFORE the suspending DB read. After the read returns + // we re-check both: a generation mismatch means clear() started a new session while we were reading, so + // the result is discarded completely; a revision mismatch means a live packet mutated the index while we + // were reading, so we merge instead of overwriting (otherwise a node received during the load window would + // be silently dropped). + val generation = sessionGeneration.value + val revisionAtStart = nodeState.value.revision + // Read from the CURRENTLY SELECTED database through DatabaseProvider.withReadDb, not from the + // process-wide nodeDBbyNum StateFlow. The StateFlow is a stateIn cache over SharingStarted.Eagerly and + // can briefly retain the PREVIOUS database's map after a currentDb switch, which would resurrect retired + // or stale rows on the new session. + val snapshot = nodeRepository.getNodeDbSnapshot() + val persistedLocalNum = nodeRepository.myNodeInfo.value?.myNodeNum + nodeState.update { state -> + if (generation != sessionGeneration.value) { + // Session was reset while the load was in flight; drop the result entirely. + state + } else { + val retiredNums = state.retiredNodeNums + // Retirement always wins: rows for currently retired numbers never re-enter the live index from + // the snapshot, even if the cache still holds them. + val filteredSnapshot = snapshot.filterKeys { it !in retiredNums } + if (state.revision == revisionAtStart) { + // No live mutation since capture: install the filtered snapshot directly. Preserve any + // local-node number learned during the session; fall back to the persisted value only when + // we have none yet. + state.copy( + index = NodeIndex.fromByNum(filteredSnapshot), + localNodeNum = state.localNodeNum ?: persistedLocalNum, + ) + } else { + // Live state changed during the load window. Merge: current nodes (with their fresher + // fields) win over snapshot rows at the same num; snapshot rows only fill slots that the + // live index does not already carry. Live local-node info and retirement state remain + // authoritative; a missing local number receives the same persisted fallback as a direct load. + val liveByNum = state.index.byNum + val merged = filteredSnapshot.toMutableMap() + liveByNum.forEach { (num, node) -> merged[num] = node } + state.copy( + index = NodeIndex.fromByNum(merged), + localNodeNum = state.localNodeNum ?: persistedLocalNum, + ) + } + } } + // Keep the published myNodeNum consistent with whatever state survived the commit above. If the load was + // discarded, the published value is whatever clear()/setMyNodeNum() left behind. + myNodeNum.value = nodeState.value.localNodeNum } } override fun clear() { - nodeIndex.value = NodeIndex() + // Bump the session generation FIRST so any in-flight loadCachedNodeDB whose suspend read returns after this + // point sees a mismatch and discards its result instead of installing stale state into the freshly cleared + // index. + sessionGeneration.incrementAndGet() + nodeState.value = NodeState() isNodeDbReady.value = false allowNodeDbWrites.value = false myNodeNum.value = null @@ -198,7 +382,7 @@ class NodeManagerImpl( override fun getMyNodeInfo(): MyNodeInfo? { val mi = nodeRepository.myNodeInfo.value ?: return null - val myNode = nodeIndex.value.byNum[mi.myNodeNum] + val myNode = nodeState.value.index.byNum[mi.myNodeNum] return MyNodeInfo( myNodeNum = mi.myNodeNum, hasGPS = (myNode?.position?.latitude_i ?: 0) != 0, @@ -218,41 +402,70 @@ class NodeManagerImpl( } override fun getMyId(): String { - val num = myNodeNum.value ?: nodeRepository.myNodeInfo.value?.myNodeNum ?: return "" - return nodeIndex.value.byNum[num]?.user?.id ?: "" + val num = nodeState.value.localNodeNum ?: nodeRepository.myNodeInfo.value?.myNodeNum ?: return "" + return nodeState.value.index.byNum[num]?.user?.id ?: "" } override fun removeByNodenum(nodeNum: Int) { - nodeIndex.update { it.remove(nodeNum) } - } - - internal fun getOrCreateNode(n: Int, channel: Int = 0): Node = nodeIndex.value.byNum[n] - ?: run { - val userId = NodeAddress.numToDefaultId(n) - val defaultUser = - User( - id = userId, - long_name = "Meshtastic ${userId.takeLast(n = 4)}", - short_name = userId.takeLast(n = 4), - hw_model = HardwareModel.UNSET, - ) + nodeState.update { state -> state.copy(index = state.index.remove(nodeNum), revision = state.revision + 1) } + } - Node(num = n, user = defaultUser, channel = channel) + override fun applyTrustedIdentityMigrations(removedNums: Collection) { + if (removedNums.isEmpty()) return + var committedHints: Map = emptyMap() + var committedPresentNums: Set = emptySet() + nodeState.update { state -> + // Capture validated public-key hints from the current index state before removal. A number that is already + // absent (e.g. re-applying the same migration after a previous retirement) has no current node to capture + // a key from — its previously captured hint MUST be preserved so same-key replay stays suppressed and a + // later genuinely different-keyed device can still claim the slot under the intended contract. + committedPresentNums = removedNums.filterTo(mutableSetOf()) { it in state.index.byNum } + committedHints = + removedNums.associateWith { num -> + state.index.byNum[num]?.let(::resolveNodePublicKeyHint) ?: state.retiredKeyHints[num] + } + state.copy( + index = removedNums.fold(state.index) { index, nodeNum -> index.remove(nodeNum) }, + retiredNodeNums = state.retiredNodeNums.addingAll(removedNums), + // Only ADD hints here; never remove a prior hint for an absent number. A subsequent migration that + // re-applies the same retirement must preserve the original hint so same-key replay stays suppressed + // and a later genuinely different-keyed device can still claim the slot. + retiredKeyHints = + state.retiredKeyHints.puttingAll( + committedHints.mapNotNull { (num, key) -> key?.let { num to it } }.toMap(), + ), + revision = state.revision + 1, + ) + } + // Commit retirement first. A dispatch already in progress will fail its final state revalidation; one that + // completed before the commit is removed by this cancellation. Side effects run once, outside the CAS loop. + removedNums.forEach { num -> + notificationManager.cancel(num) + val keyDescription = + committedHints[num]?.let(::publicKeyLogFingerprint) + ?: if (num in committedPresentNums) "none" else "absent" + Logger.d { + "[NodeIdentity] trusted-migration removed=$num" + " key=$keyDescription notificationCancelled=true" + } } + } + + internal fun getOrCreateNode(n: Int, channel: Int = 0): Node = + nodeState.value.index.byNum[n] ?: createDefaultNode(n, channel) private data class NodeStateChange(val previous: Node, val next: Node) - private fun updateNodeState(nodeNum: Int, channel: Int, transform: (Node) -> Node): NodeStateChange { - // Compute the transform against one immutable snapshot and publish it with CAS. The transform may be retried, - // so it must remain side-effect free; callers perform notifications or other effects only after this returns. - while (true) { - val index = nodeIndex.value - val current = index.byNum[nodeNum] ?: getOrCreateNode(nodeNum, channel) + private fun updateNodeState(nodeNum: Int, channel: Int, transform: (Node) -> Node): NodeStateChange? { + var change: NodeStateChange? = null + nodeState.update { state -> + change = null + if (state.isRetiredAbsent(nodeNum)) return@update state + val current = state.index.byNum[nodeNum] ?: createDefaultNode(nodeNum, channel) val next = transform(current) - if (nodeIndex.compareAndSet(index, index.put(nodeNum, next))) { - return NodeStateChange(previous = current, next = next) - } + change = NodeStateChange(previous = current, next = next) + state.copy(index = state.index.put(nodeNum, next), revision = state.revision + 1) } + return change } private fun shouldPersist(node: Node): Boolean = @@ -263,8 +476,8 @@ class NodeManagerImpl( channel: Int, session: RadioSessionContext? = null, transform: (Node) -> Node, - ): NodeStateChange = updateNodeState(nodeNum, channel, transform).also { change -> - if (shouldPersist(change.next)) { + ): NodeStateChange? = updateNodeState(nodeNum, channel, transform).also { change -> + if (change != null && shouldPersist(change.next)) { radioInterfaceService.launchSessionWork(scope, session) { persistLatestNode(nodeNum) } } } @@ -283,16 +496,13 @@ class NodeManagerImpl( } override suspend fun updateNodeAndPersist(nodeNum: Int, channel: Int, transform: (Node) -> Node) { - val result = updateNodeState(nodeNum, channel, transform).next + val result = updateNodeState(nodeNum, channel, transform)?.next ?: return if (shouldPersist(result)) persistLatestNode(nodeNum) } - /** - * Serializes persistence per node and reads the latest in-memory value inside that lane. A delayed earlier update - * therefore cannot overwrite a newer state after its repository write resumes. - */ + /** Serializes persistence per node and reads the latest in-memory value inside that lane. */ private suspend fun persistLatestNode(nodeNum: Int) = persistenceLane(nodeNum).withLock { - val latest = nodeIndex.value.byNum[nodeNum] ?: return@withLock + val latest = nodeState.value.index.byNum[nodeNum] ?: return@withLock if (shouldPersist(latest)) nodeRepository.upsert(latest) } @@ -303,41 +513,42 @@ class NodeManagerImpl( manuallyVerified: Boolean, session: RadioSessionContext?, ) { - val change = - updateNodeAndSchedulePersistence(fromNum, channel, session) { node -> - val shouldPreserve = shouldPreserveExistingUser(node.user, p) - if (shouldPreserve) { - node.copy(channel = channel, manuallyVerified = manuallyVerified) - } else { - val keyMatch = !node.hasPKC || node.user.public_key == p.public_key - val newUser = if (keyMatch) p else p.copy(public_key = ByteString.EMPTY) - node.copy( - user = newUser, - publicKey = newUser.public_key, - channel = channel, - manuallyVerified = manuallyVerified, - ) - } - } - val shouldNotify = - change.previous.isUnknownUser && - p.hw_model != HardwareModel.UNSET && - !shouldPreserveExistingUser(change.previous.user, p) - if (shouldNotify) { - val node = change.next - radioInterfaceService.launchSessionWork(scope, session) { - notificationManager.dispatch( - Notification( - title = getStringSuspend(Res.string.new_node_seen, node.user.short_name), - message = node.user.long_name, - category = Notification.Category.NodeEvent, - id = node.num, - // Path format must stay in sync with DEEP_LINK_BASE_URI + DeepLinkRouter - // in core/navigation (avoided as a Gradle dep here to keep core/data free - // of Compose Navigation libs). - deepLinkUri = "meshtastic://meshtastic/nodes/${node.num}", - ), + while (true) { + val before = nodeState.value + val transition = + reduceReceivedUser( + before.index, + fromNum, + p, + channel, + manuallyVerified, + before.localNodeNum, + before.isRetiredAbsent(fromNum), + retiredKeyHint = before.retiredKeyHints[fromNum], ) + receivedUserReductionHook?.invoke() + val after = + before.copy( + index = transition.after, + retiredNodeNums = + transition.unretireNodeNum?.let { before.retiredNodeNums.removing(it) } + ?: before.retiredNodeNums, + retiredKeyHints = + transition.unretireNodeNum?.let { before.retiredKeyHints.removing(it) } + ?: before.retiredKeyHints, + revision = before.revision + 1, + ) + if (nodeState.compareAndSet(before, after)) { + Logger.d { + val keyStr = resolveValidatedPublicKeyHint(p.public_key)?.let(::publicKeyLogFingerprint) ?: "none" + val canonicalNum = resolveValidatedPublicKeyHint(p.public_key)?.let { it.crc32().toInt() } + "[NodeIdentity] user from=$fromNum local=${before.localNodeNum}" + + " retired=${before.isRetiredAbsent(fromNum)}" + + " key=$keyStr canonical=$canonicalNum" + + " decision=${transition.decision} notify=${transition.notifyNode != null}" + } + applyReceivedUserEffects(transition, session) + return } } } @@ -413,8 +624,7 @@ class NodeManagerImpl( node.copy(nodeStatus = status?.takeIf { it.isNotEmpty() }) override fun installNodeInfo(info: ProtoNodeInfo) { - // Stage-2 configuration installation persists the full node snapshot through NodeRepository.installConfig. - // Keep this primitive in-memory so that handshake installation does not issue one redundant write per node. + // Stage-2 configuration installation persists the complete node snapshot through installConfig. updateNodeState(info.num, channel = 0) { node -> applyNodeInfo(node, info) } } @@ -449,19 +659,407 @@ class NodeManagerImpl( } override fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata, session: RadioSessionContext?) { - radioInterfaceService.launchSessionWork(scope, session) { nodeRepository.insertMetadata(nodeNum, metadata) } + if (nodeState.value.isRetiredAbsent(nodeNum)) return + radioInterfaceService.launchSessionWork(scope, session) { + if (!nodeState.value.isRetiredAbsent(nodeNum)) nodeRepository.insertMetadata(nodeNum, metadata) + } } private fun shouldPreserveExistingUser(existing: User, incoming: User): Boolean { - val isDefaultName = (incoming.long_name).matches(Regex("^Meshtastic [0-9a-fA-F]{4}$")) + val isDefaultName = incoming.long_name.matches(DEFAULT_NODE_NAME_REGEX) val isDefaultHwModel = incoming.hw_model == HardwareModel.UNSET - val hasExistingUser = (existing.id).isNotEmpty() && existing.hw_model != HardwareModel.UNSET + val hasCustomIdentity = + existing.id != incoming.id || + existing.long_name != incoming.long_name || + existing.short_name != incoming.short_name + val hasExistingUser = + existing.id.isNotEmpty() && (existing.hw_model != HardwareModel.UNSET || hasCustomIdentity) return hasExistingUser && isDefaultName && isDefaultHwModel } + /** + * Classification of one received-user reduction. Returned by [reduceReceivedUser] so callers can emit a single + * identity-routing log line per committed transition. + */ + private enum class ReceivedUserDecision { + LOCAL_UPDATE, + KNOWN_SAME_NUMBER, + GENUINE_NEW_NODE, + RETIRED_NO_KEY_SUPPRESSED, + RETIRED_WITHOUT_HINT_SUPPRESSED, + RETIRED_SAME_KEY_SUPPRESSED, + RETIRED_KEY_ALREADY_REPRESENTED, + RETIRED_DIFFERENT_KEY_REACTIVATED, + CANONICAL_DUPLICATE_RECONCILED, + STALE_PRESENTATION_REMOVED, + AMBIGUOUS_DUPLICATE_UPDATED, + IDENTITY_CONFLICT_IGNORED, + } + + /** + * Result of classifying one [User] packet against an in-memory [NodeIndex] snapshot. The reducer is pure: it only + * reads [before] and the packet fields, and produces the next index plus the side effects to apply once the CAS + * commits. Safe to re-evaluate on every retry. + */ + private data class ReceivedUserTransition( + val after: NodeIndex, + /** Ordinary same-number upsert to apply after CAS; duplicate correlation never supplies this value. */ + val upsertNode: Node?, + /** Node to fire a "new node seen" notification for (null if not a genuine new node). */ + val notifyNode: Node?, + /** Retired number to reactivate after a validated, genuinely new identity claims the vacant slot. */ + val unretireNodeNum: Int? = null, + val decision: ReceivedUserDecision, + ) + + /** + * Pure reducer that classifies an incoming [User] packet against the [before] snapshot and returns the next index + * plus queued side effects. No logging, no DB calls, no coroutine launches — deterministic and safe to re-run on + * CAS retry. + * + * A validated public key is only an in-memory correlation hint. It can choose/suppress duplicate presentations but + * cannot authorize repository deletion, metadata removal, DM remapping, or durable node-number migration. Those + * operations remain exclusively in the trusted config-install DAO path. + */ + @Suppress("CyclomaticComplexMethod", "ReturnCount", "LongMethod") + private fun reduceReceivedUser( + before: NodeIndex, + fromNum: Int, + p: User, + channel: Int, + manuallyVerified: Boolean, + myNum: Int?, + retiredAbsent: Boolean, + retiredKeyHint: ByteString? = null, + ): ReceivedUserTransition { + val resolvedKey = resolveValidatedPublicKeyHint(p.public_key) + val existing = before.byNum[fromNum] + + if (retiredAbsent) { + // Suppress same-key replay even when canonical representative hasn't yet appeared in + // the in-memory index. The retiredKeyHint was captured from the node as it was retired. + if (resolvedKey != null && retiredKeyHint != null && resolvedKey == retiredKeyHint) { + return ReceivedUserTransition( + after = before, + upsertNode = null, + notifyNode = null, + decision = ReceivedUserDecision.RETIRED_SAME_KEY_SUPPRESSED, + ) + } + // A packet with no valid key is suppressed for a retired number. + if (resolvedKey == null) { + return ReceivedUserTransition( + after = before, + upsertNode = null, + notifyNode = null, + decision = ReceivedUserDecision.RETIRED_NO_KEY_SUPPRESSED, + ) + } + // A retired slot without a trusted old-key hint has no evidence that a different-looking packet is a + // legitimate reuse rather than the stale identity. Keep it retired for this process session. + if (retiredKeyHint == null) { + return ReceivedUserTransition( + after = before, + upsertNode = null, + notifyNode = null, + decision = ReceivedUserDecision.RETIRED_WITHOUT_HINT_SUPPRESSED, + ) + } + // Key is already represented elsewhere — stale replay, suppress. + val keyAlreadyRepresented = + resolvedKey.let { key -> before.candidateNumsByPublicKey[key].orEmpty().isNotEmpty() } + if (keyAlreadyRepresented) { + return ReceivedUserTransition( + after = before, + upsertNode = null, + notifyNode = null, + decision = ReceivedUserDecision.RETIRED_KEY_ALREADY_REPRESENTED, + ) + } + // Different valid, unrepresented key — legitimate number reuse. + return sameNumberUserTransition( + before = before, + fromNum = fromNum, + existing = createDefaultNode(fromNum, channel), + incoming = p, + channel = channel, + manuallyVerified = manuallyVerified, + persist = true, + allowNotification = true, + unretireNodeNum = fromNum, + ) + .copy(decision = ReceivedUserDecision.RETIRED_DIFFERENT_KEY_REACTIVATED) + } + + if (fromNum == myNum) { + // Local-link data may persist an ordinary update at its current number. Duplicate cleanup is still + // in-memory only; trusted installConfig migration owns durable renumbering and app-local state transfer. + val otherSameKeyNums = + resolvedKey + ?.let { key -> before.candidateNumsByPublicKey[key].orEmpty().filter { it != fromNum } } + .orEmpty() + val afterRemovals = otherSameKeyNums.fold(before) { idx, num -> idx.remove(num) } + val localNode = afterRemovals.byNum[fromNum] ?: createDefaultNode(fromNum, channel) + val transformed = transformUserNode(localNode, p, channel, manuallyVerified) + return ReceivedUserTransition( + after = afterRemovals.put(fromNum, transformed, preferredNum = fromNum), + upsertNode = transformed, + notifyNode = null, + decision = ReceivedUserDecision.LOCAL_UPDATE, + ) + } + + if (resolvedKey != null) { + val canonicalNum = resolvedKey.crc32().toInt() + val existingKey = existing?.let(::resolveNodePublicKeyHint) + val otherSameKeyNums = before.candidateNumsByPublicKey[resolvedKey].orEmpty().filter { it != fromNum } + + // Fast path: a known same-number presentation needs no candidate reconciliation when it is already at the + // canonical number or has no duplicate. A canonical duplicate remains in-memory-only because an ordinary + // DAO upsert could remap the row through same-key verification. + if (existingKey == resolvedKey && (fromNum == canonicalNum || otherSameKeyNums.isEmpty())) { + return sameNumberUserTransition( + before, + fromNum, + checkNotNull(existing), + p, + channel, + manuallyVerified, + persist = otherSameKeyNums.isEmpty(), + allowNotification = false, + ) + } + + if (fromNum == canonicalNum && otherSameKeyNums.isNotEmpty()) { + val slotAcceptsCanonical = + existing == null || existingKey == resolvedKey || NodeIndex.isDefaultIdentityPlaceholder(existing) + if (!slotAcceptsCanonical) { + return ReceivedUserTransition( + after = before, + upsertNode = null, + notifyNode = null, + decision = ReceivedUserDecision.IDENTITY_CONFLICT_IGNORED, + ) + } + + // Canonical presentation may replace its own default placeholder while preserving all non-user fields. + // Same-key noncanonical representatives are removed from memory only. + val afterRemovals = otherSameKeyNums.fold(before) { index, num -> index.remove(num) } + val baseNode = afterRemovals.byNum[fromNum] ?: createDefaultNode(fromNum, channel) + val transformed = + transformUserNode(baseNode, p, channel, manuallyVerified).let { updated -> + if (existing != null && NodeIndex.isDefaultIdentityPlaceholder(existing)) { + updated.copy(channel = existing.channel) + } else { + updated + } + } + return ReceivedUserTransition( + after = afterRemovals.put(fromNum, transformed, preferredNum = fromNum), + upsertNode = null, + notifyNode = null, + decision = ReceivedUserDecision.CANONICAL_DUPLICATE_RECONCILED, + ) + } + + if (canonicalNum in otherSameKeyNums) { + // A stale remote presentation yields only when its own slot already carries the same validated hint. + // An absent slot, no-key telemetry/position placeholder, or different identity is preserved. + val after = + if (existingKey == resolvedKey) before.remove(fromNum, preferredNum = canonicalNum) else before + return ReceivedUserTransition( + after = after, + upsertNode = null, + notifyNode = null, + decision = ReceivedUserDecision.STALE_PRESENTATION_REMOVED, + ) + } + + if (otherSameKeyNums.isNotEmpty()) { + // Direction is ambiguous among noncanonical presentations. Accept an empty/default fromNum slot so a + // legitimate pre-2.8 or fixed-number peer can move between noncanonical numbers without becoming + // locked out behind a telemetry/position placeholder. Preserve established different-key identities. + val slotAcceptsAmbiguous = + existing == null || existingKey == resolvedKey || NodeIndex.isDefaultIdentityPlaceholder(existing) + if (!slotAcceptsAmbiguous) { + return ReceivedUserTransition( + after = before, + upsertNode = null, + notifyNode = null, + decision = ReceivedUserDecision.IDENTITY_CONFLICT_IGNORED, + ) + } + + // Public keys are broadcast, unauthenticated correlation hints. Installing the presentation here is + // deliberately in-memory-only: it must never authorize persistence, migration, or deletion. Trusted + // config installation remains the sole authority for durable identity changes. + val baseNode = existing ?: createDefaultNode(fromNum, channel) + val transformed = transformUserNode(baseNode, p, channel, manuallyVerified) + return ReceivedUserTransition( + after = before.put(fromNum, transformed, preferredNum = fromNum), + upsertNode = null, + notifyNode = null, + decision = ReceivedUserDecision.AMBIGUOUS_DUPLICATE_UPDATED, + ) + } + } + + return sameNumberUserTransition( + before, + fromNum, + existing ?: createDefaultNode(fromNum, channel), + p, + channel, + manuallyVerified, + persist = true, + allowNotification = true, + ) + } + + private fun sameNumberUserTransition( + before: NodeIndex, + fromNum: Int, + existing: Node, + incoming: User, + channel: Int, + manuallyVerified: Boolean, + persist: Boolean, + allowNotification: Boolean, + unretireNodeNum: Int? = null, + ): ReceivedUserTransition { + val isNewNode = existing.isUnknownUser && incoming.hw_model != HardwareModel.UNSET + val shouldPreserve = shouldPreserveExistingUser(existing.user, incoming) + val transformed = transformUserNode(existing, incoming, channel, manuallyVerified) + val notify = if (allowNotification && isNewNode && !shouldPreserve) transformed else null + val decision = + when { + allowNotification && isNewNode && !shouldPreserve -> ReceivedUserDecision.GENUINE_NEW_NODE + else -> ReceivedUserDecision.KNOWN_SAME_NUMBER + } + return ReceivedUserTransition( + after = before.put(fromNum, transformed), + upsertNode = transformed.takeIf { persist }, + notifyNode = notify, + unretireNodeNum = unretireNodeNum, + decision = decision, + ) + } + + /** Pure factory for a placeholder [Node] at [num]. Kept side-effect-free so the reducer can call it safely. */ + private fun createDefaultNode(num: Int, channel: Int): Node { + val userId = NodeAddress.numToDefaultId(num) + return Node( + num = num, + user = + User( + id = userId, + long_name = "Meshtastic ${userId.takeLast(GENERATED_NODE_NAME_SUFFIX_LENGTH)}", + short_name = userId.takeLast(GENERATED_NODE_NAME_SUFFIX_LENGTH), + hw_model = HardwareModel.UNSET, + ), + channel = channel, + ) + } + + /** + * Pure transform of an existing [node] under an incoming [User] packet (extracted from the legacy updateNode path). + */ + private fun transformUserNode(node: Node, p: User, channel: Int, manuallyVerified: Boolean): Node { + val shouldPreserve = shouldPreserveExistingUser(node.user, p) + return if (shouldPreserve) { + node.copy(channel = channel, manuallyVerified = manuallyVerified) + } else { + val incomingKey = resolveValidatedPublicKeyHint(p.public_key) + val sanitizedUser = if (incomingKey == null) p.copy(public_key = ByteString.EMPTY) else p + // Prefer node.publicKey when valid (the authoritative stored key); fall back to node.user.public_key. + val existingKey = resolveNodePublicKeyHint(node) + val keyMatch = existingKey == null || existingKey == incomingKey + val newUser = if (keyMatch) sanitizedUser else sanitizedUser.copy(public_key = ByteString.EMPTY) + node.copy( + user = newUser, + publicKey = newUser.public_key, + channel = channel, + manuallyVerified = manuallyVerified, + ) + } + } + + /** Applies ordinary same-number persistence and notification side effects once after the reducer CAS commits. */ + private fun applyReceivedUserEffects(transition: ReceivedUserTransition, session: RadioSessionContext?) { + transition.upsertNode?.let { node -> + if (shouldPersist(node)) { + radioInterfaceService.launchSessionWork(scope, session) { persistLatestNode(node.num) } + } + } + transition.notifyNode?.let { node -> + radioInterfaceService.launchSessionWork(scope, session) { + // Resolve the display title before validation so the suspending compose-resources call does + // not create a window where state can change after identity checks pass. + val title = notificationTitleFormatter(node.user.short_name) + + // Revalidate immediately before dispatch: the number may have been retired, cleared, or + // replaced by a different identity between the reducer CAS and this coroutine dispatch. + val currentState = nodeState.value + if (currentState.isRetiredAbsent(node.num)) { + Logger.d { "[NodeIdentity] notification-skip num=${node.num} reason=retired" } + return@launchSessionWork + } + val currentNode = currentState.index.byNum[node.num] + if (currentNode == null) { + Logger.d { "[NodeIdentity] notification-skip num=${node.num} reason=absent" } + return@launchSessionWork + } + if (currentState.localNodeNum == node.num) { + Logger.d { "[NodeIdentity] notification-skip num=${node.num} reason=local-node" } + return@launchSessionWork + } + // Identity comparison: prefer validated public-key equality; fall back to user.id. + // Reject if one snapshot has a key and the other does not — that is an identity change. + val nodeKey = resolveNodePublicKeyHint(node) + val currentKey = resolveNodePublicKeyHint(currentNode) + val sameIdentity = + when { + nodeKey != null && currentKey != null -> nodeKey == currentKey + + nodeKey == null && currentKey == null -> + node.user.id.isNotEmpty() && + currentNode.user.id.isNotEmpty() && + node.user.id == currentNode.user.id + + else -> false + } + if (!sameIdentity) { + Logger.d { "[NodeIdentity] notification-skip num=${node.num} reason=identity-changed" } + return@launchSessionWork + } + Logger.d { "[NodeIdentity] notification-dispatch num=${node.num}" } + notificationManager.dispatch( + Notification( + title = title, + message = node.user.long_name, + category = Notification.Category.NodeEvent, + id = node.num, + deepLinkUri = "meshtastic://meshtastic/nodes/${node.num}", + ), + ) + } + } + } + + /** + * Test seam over the notification title; production resolves compose-resources lazily inside the dispatch + * coroutine. + */ + internal var notificationTitleFormatter: suspend (String) -> String = { shortName -> + getStringSuspend(Res.string.new_node_seen, shortName) + } + + /** Test seam invoked after reduction but before CAS to deterministically race a local-node-number update. */ + internal var receivedUserReductionHook: (() -> Unit)? = null + override fun toNodeID(nodeNum: Int): String = if (nodeNum == NodeAddress.NODENUM_BROADCAST) { NodeAddress.ID_BROADCAST } else { - nodeIndex.value.byNum[nodeNum]?.user?.id ?: NodeAddress.numToDefaultId(nodeNum) + nodeState.value.index.byNum[nodeNum]?.user?.id ?: NodeAddress.numToDefaultId(nodeNum) } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt index 34e56c2145..fee66a126a 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt @@ -227,6 +227,9 @@ class NodeRepositoryImpl( override suspend fun getUnknownNodes(): List = withContext(dispatchers.io) { nodeInfoReadDataSource.getUnknownNodes().map { it.toModel() } } + override suspend fun getNodeDbSnapshot(): Map = + withContext(dispatchers.io) { nodeInfoReadDataSource.getNodeDbSnapshot().mapValues { (_, it) -> it.toModel() } } + /** Persists hardware metadata for a node. */ override suspend fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) = withContext(dispatchers.io) { nodeInfoWriteDataSource.upsert(MetadataEntity(nodeNum, metadata)) } diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt index bac2ca4e4e..3d29453e07 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt @@ -257,6 +257,28 @@ interface NodeInfoDao { >, > + /** + * One-shot snapshot of every node row in the CURRENTLY SELECTED database (not the process-wide stateIn cache). Used + * by session-safe cache loads (see NodeManagerImpl.loadCachedNodeDB) so a transport switch can't deliver a stale + * pre-switch map. + */ + @Query( + """ + SELECT * FROM nodes + ORDER BY CASE + WHEN num = (SELECT myNodeNum FROM my_node LIMIT 1) THEN 0 + ELSE 1 + END, + last_heard DESC + """, + ) + @Transaction + suspend fun nodeDBbyNumSnapshot(): Map< + @MapColumn(columnName = "num") + Int, + NodeWithRelations, + > + @Query( """ WITH OurNode AS ( diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt index b866da6748..15a67bd32c 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt @@ -189,7 +189,10 @@ data class Node( private const val DEFAULT_ID_SUFFIX_LENGTH = 4 private const val RELAY_NODE_SUFFIX_MASK = 0xFF - val ERROR_BYTE_STRING: ByteString = ByteArray(32) { 0 }.toByteString() + /** Size (in bytes) of a Curve25519 public key as used by meshtastic firmware. */ + const val PUBLIC_KEY_SIZE: Int = 32 + + val ERROR_BYTE_STRING: ByteString = ByteArray(PUBLIC_KEY_SIZE) { 0 }.toByteString() fun getRelayNode(relayNodeId: Int, nodes: List, ourNodeNum: Int?): Node? { val relayNodeIdSuffix = relayNodeId and RELAY_NODE_SUFFIX_MASK diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt index 58bd8cfa3c..71d1265c92 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt @@ -183,6 +183,12 @@ interface NodeManager : NodeIdLookup { /** Removes a node from the in-memory database by its number. */ fun removeByNodenum(nodeNum: Int) + /** + * Applies node-number removals returned by the trusted configuration install. Removed numbers stay retired for the + * current device/database session so delayed field packets cannot recreate migrated identities. + */ + fun applyTrustedIdentityMigrations(removedNums: Collection) + /** * Installs node information in memory. Callers that require an immediate standalone write use * [installNodeInfoAndPersist]; configuration handshakes batch the resulting snapshot through the repository. diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt index 833f408361..80992d6ac9 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt @@ -121,6 +121,13 @@ interface NodeRepository { /** Returns all nodes with unknown hardware models. */ suspend fun getUnknownNodes(): List + /** + * One-shot snapshot of every node in the CURRENTLY SELECTED database (not the process-wide [nodeDBbyNum] `stateIn` + * cache, which can briefly retain the previous transport's map after a switch). Used by session-safe cache loads + * that need to read the live DB at invocation time. + */ + suspend fun getNodeDbSnapshot(): Map + /** * Deletes all nodes from the database. * diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt index 3e9b013688..9b05018319 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt @@ -131,6 +131,8 @@ class FakeNodeRepository : override suspend fun getUnknownNodes(): List = _nodeDBbyNum.value.values.filter { it.isUnknownUser } + override suspend fun getNodeDbSnapshot(): Map = _nodeDBbyNum.value + override suspend fun clearNodeDB(preserveFavorites: Boolean) { if (preserveFavorites) { _nodeDBbyNum.value = _nodeDBbyNum.value.filter { it.value.isFavorite } From 396026f37b3cb3f9b76ff13f897a416e5d1dab2a Mon Sep 17 00:00:00 2001 From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:15:29 -0500 Subject: [PATCH 2/2] test(node): cover post-migration identity replay behavior Exercise the identity reducer and trusted-migration boundary across canonical, noncanonical, placeholder, conflict, and local-node presentations. Verify deterministic representative selection and reverse-index consistency as node IDs, numbers, and public keys arrive in different orders. Cover delayed replay suppression for every mutable packet field handled by the manager, including telemetry, position, paxcounter, status, NodeInfo, metadata, keyless User, invalid-key User, and User packets carrying an already represented key. Confirm that remote correlation remains in memory and does not invoke durable packet-triggered deletion. Exercise ambiguous noncanonical renumbering into both an empty slot and a position-derived default placeholder. Prove the incoming identity remains in memory only, preserves placeholder telemetry, accepts subsequent User updates, retains the previous candidate, and emits neither persistence nor notification effects. Use suspending title-format barriers to pause genuine notification candidates after reducer commit but before dispatch. Change retirement, local-node, and identity state while paused, then prove exact stale notifications are skipped. Observe notification cancellation directly to verify trusted retirement is committed before its side effects run. Verify keyless trusted retirements remain closed even for an unrepresented valid key, same-key replay stays suppressed without a canonical candidate, and different-key reuse requires a captured trusted hint. Confirm accepted reuse clears both retirement collections and emits one replacement notification, while complete 2.7-to-2.8 migration suppresses early old-number replay. Retain unrelated first-sighting coverage, clear/reload session isolation, trusted installConfig ordering, and a direct assertion that diagnostic key values are one-way fingerprints rather than raw prefixes. Keep fixtures aligned with the suspending notification API and the current-database snapshot used by bounded cache loading. Run coroutine fixtures through their existing TestScope so JVM, Android-host, and Native compilation share the same scheduler and exception ownership. --- .../core/data/manager/NodeManagerImpl.kt | 8 +- .../manager/MeshConfigFlowManagerImplTest.kt | 31 + .../NodeManagerConnectionIdentityTest.kt | 2 + .../core/data/manager/NodeManagerImplTest.kt | 1859 +++++++++++++++++ .../core/takserver/TAKMeshIntegrationTest.kt | 3 + 5 files changed, 1899 insertions(+), 4 deletions(-) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt index da7e33cfa6..4b286c02b0 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt @@ -531,11 +531,11 @@ class NodeManagerImpl( before.copy( index = transition.after, retiredNodeNums = - transition.unretireNodeNum?.let { before.retiredNodeNums.removing(it) } - ?: before.retiredNodeNums, + transition.unretireNodeNum?.let { before.retiredNodeNums.removing(it) } + ?: before.retiredNodeNums, retiredKeyHints = - transition.unretireNodeNum?.let { before.retiredKeyHints.removing(it) } - ?: before.retiredKeyHints, + transition.unretireNodeNum?.let { before.retiredKeyHints.removing(it) } + ?: before.retiredKeyHints, revision = before.revision + 1, ) if (nodeState.compareAndSet(before, after)) { diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt index c79bf87f95..4d227c9a40 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt @@ -626,6 +626,37 @@ class MeshConfigFlowManagerImplTest { verifySuspend { connectionManager.onNodeDbReady() } } + @Test + fun `Stage 2 applies trusted migrations before readiness and replay`() = testScope.runTest { + val retiredNum = 456 + val callOrder = mutableListOf() + everySuspend { nodeRepository.installConfig(any(), any()) } calls + { + callOrder.add("installConfig") + listOf(retiredNum) + } + every { nodeManager.applyTrustedIdentityMigrations(any()) } calls { callOrder.add("applyMigrations") } + every { nodeManager.setNodeDbReady(true) } calls { callOrder.add("nodeDbReady") } + every { nodeManager.setAllowNodeDbWrites(true) } calls { callOrder.add("writesReady") } + everySuspend { connectionManager.onNodeDbReady() } calls { callOrder.add("replayReady") } + + handleMyInfo(protoMyNodeInfo) + advanceUntilIdle() + manager.handleLocalMetadata(metadata) + advanceUntilIdle() + manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE) + advanceTimeBy(STAGE_TRANSITION_ADVANCE_MS) + runCurrent() + manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE) + advanceUntilIdle() + + assertEquals( + listOf("installConfig", "applyMigrations", "nodeDbReady", "writesReady", "replayReady"), + callOrder, + ) + verify { nodeManager.applyTrustedIdentityMigrations(listOf(retiredNum)) } + } + @Test fun `Stage 2 complete id ignored when not in ReceivingNodeInfo state`() = testScope.runTest { manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE) diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerConnectionIdentityTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerConnectionIdentityTest.kt index 8140f24a5f..d440f8ac40 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerConnectionIdentityTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerConnectionIdentityTest.kt @@ -19,6 +19,7 @@ package org.meshtastic.core.data.manager import dev.mokkery.MockMode import dev.mokkery.answering.returns import dev.mokkery.every +import dev.mokkery.everySuspend import dev.mokkery.mock import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch @@ -55,6 +56,7 @@ class NodeManagerConnectionIdentityTest { @BeforeTest fun setUp() { + everySuspend { nodeRepository.getNodeDbSnapshot() } returns emptyMap() nodeManager = NodeManagerImpl(nodeRepository, notificationManager, radioInterfaceService, testScope) } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt index fb5f9621ec..74e48fefc5 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt @@ -22,7 +22,10 @@ import dev.mokkery.answering.returns import dev.mokkery.every import dev.mokkery.everySuspend import dev.mokkery.matcher.any +import dev.mokkery.matcher.capture.capture import dev.mokkery.mock +import dev.mokkery.verify +import dev.mokkery.verify.VerifyMode import dev.mokkery.verify.VerifyMode.Companion.exactly import dev.mokkery.verifySuspend import kotlinx.coroutines.CompletableDeferred @@ -30,20 +33,26 @@ import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import okio.ByteString import okio.ByteString.Companion.toByteString +import org.meshtastic.core.common.util.crc32 import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.Notification import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioSessionContext +import org.meshtastic.proto.DeviceMetadata import org.meshtastic.proto.DeviceMetrics import org.meshtastic.proto.EnvironmentMetrics import org.meshtastic.proto.HardwareModel +import org.meshtastic.proto.Paxcount +import org.meshtastic.proto.StatusMessage import org.meshtastic.proto.Telemetry import org.meshtastic.proto.User import kotlin.test.BeforeTest @@ -68,6 +77,10 @@ class NodeManagerImplTest { @BeforeTest fun setUp() { nodeManager = NodeManagerImpl(nodeRepository, notificationManager, radioInterfaceService, testScope) + // Override the compose-resources formatter so notification dispatch is deterministic in the + // plain-JVM test env (getStringSuspend does not resolve here). Tests that assert "no dispatch" + // still hold: the override only changes the title, not whether dispatch fires. + nodeManager.notificationTitleFormatter = { shortName -> "New node seen: $shortName" } } @Test @@ -548,4 +561,1850 @@ class NodeManagerImplTest { val result = nodeManager.getMyId() assertEquals("!mynode42", result) } + + // ---------- Atomic identity reconciliation ---------- + // + // Covers the CAS-loop reducer that replaced the legacy read-then-mutate sequence. Each test asserts typed + // in-memory state and the permitted side effects (ordinary same-number upsert and notification dispatch). + + private val validPk = ByteArray(32) { (it + 1).toByte() }.toByteString() + + private fun ByteString.canonicalNum(): Int = crc32().toInt() + + private fun ByteString.noncanonicalNum(preferred: Int): Int = + if (preferred != canonicalNum()) preferred else preferred + 1 + + private fun makeKnownNode(num: Int, pk: ByteString, name: String = "Known"): Node = Node( + num = num, + user = + User( + id = "!${num.toString(16)}", + long_name = name, + short_name = name.take(3), + hw_model = HardwareModel.TLORA_V2, + public_key = pk, + ), + publicKey = pk, + ) + + private fun enableDbWrites() { + nodeManager.setNodeDbReady(true) + nodeManager.setAllowNodeDbWrites(true) + } + + private fun verifyNoRepositoryDeletion() { + verifySuspend(mode = VerifyMode.not) { nodeRepository.deleteNode(any()) } + verifySuspend(mode = VerifyMode.not) { nodeRepository.deleteNodes(any()) } + } + + // ---------- Trusted migration retirement ---------- + + @Test + fun `retired number rejects delayed field sequence without persistence`() { + val retiredNum = validPk.noncanonicalNum(4100) + nodeManager.updateNode(retiredNum) { makeKnownNode(retiredNum, validPk, "Pre-migration") } + nodeManager.applyTrustedIdentityMigrations(listOf(retiredNum)) + enableDbWrites() + + nodeManager.handleReceivedTelemetry(retiredNum, Telemetry(device_metrics = DeviceMetrics(battery_level = 50))) + nodeManager.handleReceivedPosition( + retiredNum, + myNodeNum = 9999, + p = ProtoPosition(latitude_i = 123, longitude_i = 456), + defaultTime = 1000L, + ) + nodeManager.handleReceivedPaxcounter(retiredNum, Paxcount(wifi = 10, ble = 5, uptime = 1000)) + nodeManager.handleReceivedNodeStatus(retiredNum, StatusMessage(status = "stale")) + nodeManager.installNodeInfo( + ProtoNodeInfo( + num = retiredNum, + user = + User( + id = "!retired", + long_name = "Retired", + short_name = "RET", + hw_model = HardwareModel.TLORA_V2, + public_key = validPk, + ), + ), + ) + nodeManager.insertMetadata(retiredNum, DeviceMetadata(firmware_version = "2.7.0")) + testScope.advanceUntilIdle() + + assertNull(nodeManager.nodeDBbyNodeNum[retiredNum]) + verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) } + verifySuspend(mode = VerifyMode.not) { nodeRepository.insertMetadata(any(), any()) } + verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) } + } + + @Test + fun `retired number suppresses keyless and represented-key user replays`() { + val retiredNum = validPk.noncanonicalNum(4200) + val canonicalNum = validPk.canonicalNum() + nodeManager.updateNode(canonicalNum) { makeKnownNode(canonicalNum, validPk, "Canonical") } + nodeManager.updateNode(retiredNum) { makeKnownNode(retiredNum, validPk, "Retired") } + nodeManager.applyTrustedIdentityMigrations(listOf(retiredNum)) + enableDbWrites() + + nodeManager.handleReceivedUser( + retiredNum, + User(id = "!keyless", long_name = "Keyless replay", short_name = "KEY", hw_model = HardwareModel.TLORA_V2), + ) + nodeManager.handleReceivedUser( + retiredNum, + User( + id = "!invalid", + long_name = "Invalid-key replay", + short_name = "INV", + hw_model = HardwareModel.TLORA_V2, + public_key = byteArrayOf(1, 2, 3).toByteString(), + ), + ) + nodeManager.handleReceivedUser( + retiredNum, + User( + id = "!represented", + long_name = "Represented replay", + short_name = "REP", + hw_model = HardwareModel.TLORA_V2, + public_key = validPk, + ), + ) + testScope.advanceUntilIdle() + + assertNull(nodeManager.nodeDBbyNodeNum[retiredNum]) + assertEquals("Canonical", nodeManager.nodeDBbyNodeNum[canonicalNum]?.user?.long_name) + verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) } + verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) } + } + + @Test + fun `retired number accepts validated new-key reuse and un-retires the slot`() { + // Seed a trusted old identity at the number FIRST, so the migration retires it with a trusted key hint. Without + // the hint, a later key packet would be suppressed as RETIRED_WITHOUT_HINT_SUPPRESSED and the slot would stay + // retired for the session (see reduceReceivedUser). + val retiredNum = validPk.noncanonicalNum(4300) + val replacementKey = ByteArray(32) { (it + 80).toByte() }.toByteString() + nodeManager.updateNode(retiredNum) { makeKnownNode(retiredNum, validPk, "Old") } + testScope.advanceUntilIdle() + nodeManager.applyTrustedIdentityMigrations(listOf(retiredNum)) + enableDbWrites() + + nodeManager.handleReceivedUser( + retiredNum, + User( + id = "!replacement", + long_name = "Replacement", + short_name = "NEW", + hw_model = HardwareModel.TLORA_V2, + public_key = replacementKey, + ), + ) + testScope.advanceUntilIdle() + + val replacement = nodeManager.nodeDBbyNodeNum[retiredNum] + assertNotNull(replacement) + assertEquals(replacementKey, replacement.publicKey) + assertEquals("Replacement", replacement.user.long_name) + verifySuspend(mode = VerifyMode.exactly(1)) { nodeRepository.upsert(any()) } + verifySuspend(mode = VerifyMode.exactly(1)) { notificationManager.dispatch(any()) } + } + + @Test + fun `loadCachedNodeDB preserves retirement and suppresses reloaded stale replay`() { + // The repo still holds the retired node's row; a cache reload must keep the trusted retirement and must NOT + // resurrect the retired number into the live index, and a later stale replay at that number stays suppressed. + val retiredNum = validPk.noncanonicalNum(4500) + val retiredNode = makeKnownNode(retiredNum, validPk, "Retired") + every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(retiredNum to retiredNode)) + everySuspend { nodeRepository.getNodeDbSnapshot() } returns mapOf(retiredNum to retiredNode) + every { nodeRepository.myNodeInfo } returns MutableStateFlow(null) + + nodeManager.applyTrustedIdentityMigrations(listOf(retiredNum)) + enableDbWrites() + + nodeManager.loadCachedNodeDB() + testScope.advanceUntilIdle() + + // Retirement survived the reload AND the reloaded row was filtered out of the live index. + assertNull(nodeManager.nodeDBbyNodeNum[retiredNum]) + + // A stale keyless replay at the retired number is still suppressed after the reload. + nodeManager.handleReceivedUser( + retiredNum, + User(id = "!stale", long_name = "Stale replay", short_name = "STL", hw_model = HardwareModel.TLORA_V2), + ) + testScope.advanceUntilIdle() + + assertNull(nodeManager.nodeDBbyNodeNum[retiredNum]) + verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) } + verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) } + } + + @Test + fun `clear starts a new session without retired-number suppression`() { + val retiredNum = validPk.noncanonicalNum(4400) + nodeManager.applyTrustedIdentityMigrations(listOf(retiredNum)) + nodeManager.handleReceivedTelemetry(retiredNum, Telemetry(device_metrics = DeviceMetrics(battery_level = 10))) + assertNull(nodeManager.nodeDBbyNodeNum[retiredNum]) + + nodeManager.clear() + nodeManager.handleReceivedTelemetry(retiredNum, Telemetry(device_metrics = DeviceMetrics(battery_level = 90))) + + assertEquals(90, nodeManager.nodeDBbyNodeNum[retiredNum]?.deviceMetrics?.battery_level) + } + + // 1. Established same-key duplicate + + @Test + fun `established same-key stale replay is removed in memory without repository mutation`() { + val oldNum = validPk.noncanonicalNum(1000) + val newNum = validPk.canonicalNum() + nodeManager.updateNode(newNum) { makeKnownNode(newNum, validPk, "Migrated") } + nodeManager.updateNode(oldNum) { makeKnownNode(oldNum, validPk, "Stale Established") } + enableDbWrites() + + val staleUser = + User( + id = "!old", + long_name = "Stale", + short_name = "STL", + hw_model = HardwareModel.TLORA_V2, + public_key = validPk, + ) + nodeManager.handleReceivedUser(oldNum, staleUser) + testScope.advanceUntilIdle() + + assertNull(nodeManager.nodeDBbyNodeNum[oldNum]) + val canonical = nodeManager.nodeDBbyNodeNum[newNum] + assertNotNull(canonical) + assertEquals("Migrated", canonical!!.user.long_name) + assertEquals(validPk, canonical.publicKey) + verifyNoRepositoryDeletion() + verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) } + verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) } + } + + // 2. Placeholder duplicate + + @Test + fun `stale replay preserves no-key telemetry placeholder and canonical identity`() { + val oldNum = validPk.noncanonicalNum(1000) + val newNum = validPk.canonicalNum() + nodeManager.updateNode(newNum) { makeKnownNode(newNum, validPk, "Migrated") } + val placeholderKey = ByteString.EMPTY + val defaultId = NodeAddress.numToDefaultId(oldNum) + nodeManager.updateNode(oldNum) { + Node( + num = oldNum, + user = + User( + id = defaultId, + long_name = "Meshtastic ${defaultId.takeLast(4)}", + short_name = defaultId.takeLast(4), + hw_model = HardwareModel.UNSET, + public_key = placeholderKey, + ), + publicKey = placeholderKey, + position = ProtoPosition(latitude_i = 123, longitude_i = 456), + channel = 7, + ) + } + enableDbWrites() + + val staleUser = + User( + id = "!old", + long_name = "Stale", + short_name = "STL", + hw_model = HardwareModel.TLORA_V2, + public_key = validPk, + ) + nodeManager.handleReceivedUser(oldNum, staleUser) + testScope.advanceUntilIdle() + + val placeholder = nodeManager.nodeDBbyNodeNum[oldNum] + assertNotNull(placeholder) + assertEquals(defaultId, placeholder.user.id) + assertEquals(123, placeholder.position.latitude_i) + assertEquals(456, placeholder.position.longitude_i) + assertEquals(7, placeholder.channel) + val canonical = nodeManager.nodeDBbyNodeNum[newNum] + assertNotNull(canonical) + assertEquals("Migrated", canonical!!.user.long_name) + verifyNoRepositoryDeletion() + verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) } + verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) } + } + + // 3. Different-key conflict + + @Test + fun `different-key conflict preserves both nodes and emits no side effects`() { + val fromNum = 1000 + val otherNum = 2000 + val differentPk = ByteArray(32) { (it + 50).toByte() }.toByteString() + nodeManager.updateNode(otherNum) { makeKnownNode(otherNum, validPk, "Other") } + nodeManager.updateNode(fromNum) { makeKnownNode(fromNum, differentPk, "Established") } + enableDbWrites() + + val incomingUser = + User( + id = "!stale", + long_name = "Stale", + short_name = "STL", + hw_model = HardwareModel.TLORA_V2, + public_key = validPk, + ) + nodeManager.handleReceivedUser(fromNum, incomingUser) + testScope.advanceUntilIdle() + + val fromNode = nodeManager.nodeDBbyNodeNum[fromNum] + assertNotNull(fromNode) + assertEquals("Established", fromNode!!.user.long_name) + assertEquals(differentPk, fromNode.publicKey) + val otherNode = nodeManager.nodeDBbyNodeNum[otherNum] + assertNotNull(otherNode) + assertEquals("Other", otherNode!!.user.long_name) + verifyNoRepositoryDeletion() + verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) } + verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) } + } + + // 4. Multiple same-key matches + + @Test + fun `ambiguous noncanonical duplicates update from entry and preserve every candidate`() { + val nodeA = validPk.noncanonicalNum(1000) + val nodeB = validPk.noncanonicalNum(2000) + val fromNum = validPk.noncanonicalNum(3000) + nodeManager.updateNode(nodeA) { makeKnownNode(nodeA, validPk, "Alpha") } + nodeManager.updateNode(nodeB) { makeKnownNode(nodeB, validPk, "Bravo") } + nodeManager.updateNode(fromNum) { makeKnownNode(fromNum, validPk, "Before") } + enableDbWrites() + + val incomingUser = + User( + id = "!incoming", + long_name = "Incoming", + short_name = "INC", + hw_model = HardwareModel.TLORA_V2, + public_key = validPk, + ) + nodeManager.handleReceivedUser(fromNum, incomingUser) + testScope.advanceUntilIdle() + + // No candidate is removed, but future packets keep updating their own established fromNum entry. + assertNotNull(nodeManager.nodeDBbyNodeNum[nodeA]) + assertNotNull(nodeManager.nodeDBbyNodeNum[nodeB]) + assertEquals("Incoming", nodeManager.nodeDBbyNodeNum[fromNum]?.user?.long_name) + verifyNoRepositoryDeletion() + verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) } + verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) } + } + + @Test + fun `ambiguous noncanonical renumber installs identity into empty slot in memory`() { + val previousNum = validPk.noncanonicalNum(3100) + val fromNum = validPk.noncanonicalNum(3200) + nodeManager.updateNode(previousNum) { makeKnownNode(previousNum, validPk, "Previous") } + enableDbWrites() + + nodeManager.handleReceivedUser( + fromNum, + User( + id = "!incoming", + long_name = "Incoming", + short_name = "INC", + hw_model = HardwareModel.TLORA_V2, + public_key = validPk, + ), + ) + testScope.advanceUntilIdle() + + assertNotNull(nodeManager.nodeDBbyNodeNum[previousNum]) + val incoming = assertNotNull(nodeManager.nodeDBbyNodeNum[fromNum]) + assertEquals("Incoming", incoming.user.long_name) + assertEquals(validPk, incoming.publicKey) + verifyNoRepositoryDeletion() + verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) } + verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) } + } + + @Test + fun `ambiguous noncanonical renumber replaces telemetry placeholder without session lockout`() { + val previousNum = validPk.noncanonicalNum(3300) + val fromNum = validPk.noncanonicalNum(3400) + nodeManager.updateNode(previousNum) { makeKnownNode(previousNum, validPk, "Previous") } + nodeManager.handleReceivedPosition( + fromNum = fromNum, + myNodeNum = 9999, + p = ProtoPosition(latitude_i = 123, longitude_i = 456), + defaultTime = 1000L, + ) + enableDbWrites() + + nodeManager.handleReceivedUser( + fromNum, + User( + id = "!incoming", + long_name = "First Identity", + short_name = "ONE", + hw_model = HardwareModel.TLORA_V2, + public_key = validPk, + ), + ) + nodeManager.handleReceivedUser( + fromNum, + User( + id = "!incoming", + long_name = "Updated Identity", + short_name = "TWO", + hw_model = HardwareModel.TLORA_V2, + public_key = validPk, + ), + ) + testScope.advanceUntilIdle() + + assertNotNull(nodeManager.nodeDBbyNodeNum[previousNum]) + val incoming = assertNotNull(nodeManager.nodeDBbyNodeNum[fromNum]) + assertEquals("Updated Identity", incoming.user.long_name) + assertEquals(validPk, incoming.publicKey) + assertEquals(123, incoming.position.latitude_i) + assertEquals(456, incoming.position.longitude_i) + verifyNoRepositoryDeletion() + verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) } + verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) } + } + + // 5. Local authoritative renumber + + @Test + fun `local authoritative renumber removes old same-key entries in memory upserts local no notification`() { + val localNum = 5000 + val otherNum = 6000 + nodeManager.setMyNodeNum(localNum) + nodeManager.updateNode(otherNum) { makeKnownNode(otherNum, validPk, "Old Same-Key") } + enableDbWrites() + + val localUser = + User( + id = "!local", + long_name = "Local Node", + short_name = "LCL", + hw_model = HardwareModel.TLORA_V2, + public_key = validPk, + ) + nodeManager.handleReceivedUser(localNum, localUser) + testScope.advanceUntilIdle() + + assertNull(nodeManager.nodeDBbyNodeNum[otherNum]) + val localNode = nodeManager.nodeDBbyNodeNum[localNum] + assertNotNull(localNode) + assertEquals("Local Node", localNode!!.user.long_name) + assertEquals(validPk, localNode.publicKey) + verifyNoRepositoryDeletion() + verifySuspend(mode = VerifyMode.exactly(1)) { nodeRepository.upsert(any()) } + verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) } + } + + // 6. Genuine new node + + @Test + fun `genuine new node fires exactly one upsert and exactly one notification`() { + val newNodeNum = 3000 + val newPk = ByteArray(32) { (it + 100).toByte() }.toByteString() + val newUser = + User( + id = "!newnode", + long_name = "New Node", + short_name = "NEW", + hw_model = HardwareModel.TLORA_V2, + public_key = newPk, + ) + enableDbWrites() + + val captured = mutableListOf() + everySuspend { notificationManager.dispatch(capture(captured)) } returns true + + nodeManager.handleReceivedUser(newNodeNum, newUser) + testScope.advanceUntilIdle() + + val result = nodeManager.nodeDBbyNodeNum[newNodeNum] + assertNotNull(result) + assertEquals("New Node", result!!.user.long_name) + assertEquals(newPk, result.publicKey) + verifyNoRepositoryDeletion() + verifySuspend(mode = VerifyMode.exactly(1)) { nodeRepository.upsert(any()) } + // Strengthen: capture the dispatched Notification and verify payload + routing fields, not just the call count. + assertEquals(1, captured.size) + val n = captured.first() + assertEquals(newNodeNum, n.id) + assertEquals("New Node", n.message) + assertEquals(Notification.Category.NodeEvent, n.category) + assertEquals("meshtastic://meshtastic/nodes/$newNodeNum", n.deepLinkUri) + } + + // 7. Invalid / malformed keys (null, empty, ERROR) are treated as NoMatch + + @Test + fun `invalid public keys skip identity matching and update normally`() { + val existingNum = 7000 + val existingPk = ByteArray(32) { (it + 1).toByte() }.toByteString() + nodeManager.updateNode(existingNum) { makeKnownNode(existingNum, existingPk, "Canonical") } + enableDbWrites() + + // Three packets at a different number without a validated public-key correlation hint. + // None of them should match the canonical node at 7000 or trigger stale cleanup. + val targetNum = 8000 + + // (a) null key + nodeManager.handleReceivedUser( + targetNum, + User(id = "!a", long_name = "A", short_name = "A", hw_model = HardwareModel.TLORA_V2), + ) + testScope.advanceUntilIdle() + + // (b) empty key + nodeManager.handleReceivedUser( + targetNum, + User( + id = "!b", + long_name = "B", + short_name = "B", + hw_model = HardwareModel.TLORA_V2, + public_key = ByteString.EMPTY, + ), + ) + testScope.advanceUntilIdle() + + // (c) ERROR key + nodeManager.handleReceivedUser( + targetNum, + User( + id = "!c", + long_name = "C", + short_name = "C", + hw_model = HardwareModel.TLORA_V2, + public_key = Node.ERROR_BYTE_STRING, + ), + ) + testScope.advanceUntilIdle() + + // Canonical node untouched (no stale cleanup). + assertNotNull(nodeManager.nodeDBbyNodeNum[existingNum]) + } + + // 8. Same number same key is a normal update, not a cross-number stale + + @Test + fun `same number same key updates normally without stale classification`() { + val nodeNum = 9000 + nodeManager.updateNode(nodeNum) { makeKnownNode(nodeNum, validPk, "Original") } + enableDbWrites() + + val updatedUser = + User( + id = "!${nodeNum.toString(16)}", + long_name = "Updated", + short_name = "UPD", + hw_model = HardwareModel.TLORA_V2, + public_key = validPk, + ) + nodeManager.handleReceivedUser(nodeNum, updatedUser) + testScope.advanceUntilIdle() + + val result = nodeManager.nodeDBbyNodeNum[nodeNum] + assertNotNull(result) + assertEquals("Updated", result!!.user.long_name) + assertEquals(validPk, result.publicKey) + verifyNoRepositoryDeletion() + verifySuspend(mode = VerifyMode.exactly(1)) { nodeRepository.upsert(any()) } + } + + // 9. Packet-driven duplicate cleanup stays entirely in memory. + + @Test + fun `stale duplicate never deletes or upserts a repository row`() { + val oldNum = validPk.noncanonicalNum(1000) + val newNum = validPk.canonicalNum() + nodeManager.updateNode(newNum) { makeKnownNode(newNum, validPk, "Canonical") } + nodeManager.updateNode(oldNum) { makeKnownNode(oldNum, validPk, "Stale Dup") } + enableDbWrites() + + val staleUser = + User( + id = "!old", + long_name = "Stale Packet", + short_name = "STL", + hw_model = HardwareModel.TLORA_V2, + public_key = validPk, + ) + nodeManager.handleReceivedUser(oldNum, staleUser) + testScope.advanceUntilIdle() + + assertNull(nodeManager.nodeDBbyNodeNum[oldNum]) + verifyNoRepositoryDeletion() + verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) } + } + + // 10. No notification for stale / conflict / local outcomes + + @Test + fun `notification never fires for stale conflict or local outcomes`() { + // Stale: stale packet at old number with established canonical elsewhere. + val staleOld = validPk.noncanonicalNum(1000) + val staleCanonical = validPk.canonicalNum() + nodeManager.updateNode(staleCanonical) { makeKnownNode(staleCanonical, validPk, "Canonical") } + nodeManager.updateNode(staleOld) { makeKnownNode(staleOld, validPk, "Old") } + nodeManager.handleReceivedUser( + staleOld, + User(id = "!s", long_name = "S", short_name = "S", hw_model = HardwareModel.TLORA_V2, public_key = validPk), + ) + + // Conflict: different established identity at fromNum. + val conflictFrom = 3000 + val conflictOther = 4000 + val conflictPk = ByteArray(32) { (it + 50).toByte() }.toByteString() + nodeManager.updateNode(conflictOther) { makeKnownNode(conflictOther, validPk, "Other") } + nodeManager.updateNode(conflictFrom) { makeKnownNode(conflictFrom, conflictPk, "Established") } + nodeManager.handleReceivedUser( + conflictFrom, + User(id = "!c", long_name = "C", short_name = "C", hw_model = HardwareModel.TLORA_V2, public_key = validPk), + ) + + // Local: fromNum == myNodeNum, local-link authoritative update. + val localNum = 5000 + val localGhost = 6000 + nodeManager.setMyNodeNum(localNum) + nodeManager.updateNode(localGhost) { makeKnownNode(localGhost, validPk, "Ghost") } + nodeManager.handleReceivedUser( + localNum, + User(id = "!l", long_name = "L", short_name = "L", hw_model = HardwareModel.TLORA_V2, public_key = validPk), + ) + + testScope.advanceUntilIdle() + + // No notification fired for any of the three outcomes. + verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) } + } + + // 11. byId consistency after stale removal — when stale and canonical share a user ID, + // removing the stale node must not orphan the canonical node in the byId index. + + @Test + fun `byId index points to canonical survivor after stale removal shares user id`() { + val oldNum = validPk.noncanonicalNum(1000) + val newNum = validPk.canonicalNum() + val sharedUserId = "!shared" + nodeManager.updateNode(newNum) { + Node( + num = newNum, + user = + User( + id = sharedUserId, + long_name = "Canonical", + short_name = "CAN", + hw_model = HardwareModel.TLORA_V2, + public_key = validPk, + ), + publicKey = validPk, + ) + } + nodeManager.updateNode(oldNum) { + Node( + num = oldNum, + user = + User( + id = sharedUserId, + long_name = "Stale", + short_name = "STL", + hw_model = HardwareModel.TLORA_V2, + public_key = validPk, + ), + publicKey = validPk, + ) + } + enableDbWrites() + + val staleUser = + User( + id = sharedUserId, + long_name = "Stale", + short_name = "STL", + hw_model = HardwareModel.TLORA_V2, + public_key = validPk, + ) + nodeManager.handleReceivedUser(oldNum, staleUser) + testScope.advanceUntilIdle() + + assertNull(nodeManager.nodeDBbyNodeNum[oldNum]) + val survivor = nodeManager.getNodeById(sharedUserId) + assertNotNull(survivor) + assertEquals(newNum, survivor!!.num) + } + + // ── Additional identity reconciliation tests ──────────────────────────────── + + // 12. Malformed key lengths (1, 31, 33) are rejected + + @Test + fun `malformed key lengths 1 31 33 are rejected and skip identity matching`() { + val nodeNum = 1234 + val shortKey = ByteArray(1) { 1 }.toByteString() + val almostKey = ByteArray(31) { 2 }.toByteString() + val longKey = ByteArray(33) { 3 }.toByteString() + + // All malformed keys should be treated as no-key (normal update path). + nodeManager.handleReceivedUser( + nodeNum, + User( + id = "!short", + long_name = "S", + short_name = "S", + hw_model = HardwareModel.TLORA_V2, + public_key = shortKey, + ), + ) + var result = nodeManager.nodeDBbyNodeNum[nodeNum] + assertEquals(ByteString.EMPTY, result!!.publicKey) + + nodeManager.handleReceivedUser( + nodeNum, + User( + id = "!almost", + long_name = "A", + short_name = "A", + hw_model = HardwareModel.TLORA_V2, + public_key = almostKey, + ), + ) + result = nodeManager.nodeDBbyNodeNum[nodeNum] + assertEquals(ByteString.EMPTY, result!!.publicKey) + + nodeManager.handleReceivedUser( + nodeNum, + User( + id = "!long", + long_name = "L", + short_name = "L", + hw_model = HardwareModel.TLORA_V2, + public_key = longKey, + ), + ) + result = nodeManager.nodeDBbyNodeNum[nodeNum] + assertEquals(ByteString.EMPTY, result!!.publicKey) + } + + // 13. Invalid Node.publicKey falls back to valid User.public_key + + @Test + fun `invalid Node publicKey falls back to valid User public_key`() { + val nodeNum = 1234 + val validUserKey = ByteArray(32) { 42 }.toByteString() + val invalidNodeKey = ByteArray(16) { 1 }.toByteString() // Wrong size + + nodeManager.handleReceivedUser( + nodeNum, + User( + id = "!test", + long_name = "Test", + short_name = "T", + hw_model = HardwareModel.TLORA_V2, + public_key = validUserKey, + ), + ) + // Manually set an invalid Node.publicKey + nodeManager.updateNode(nodeNum) { it.copy(publicKey = invalidNodeKey) } + + // Public-key correlation should fall back to User.public_key. + val node = nodeManager.nodeDBbyNodeNum[nodeNum] + val resolvedKey = nodeManager.resolveNodePublicKeyHint(node!!) + assertEquals(validUserKey, resolvedKey) + } + + // 14. UNSET hardware with a different valid key is preserved (conflict) + + @Test + fun `UNSET hardware with different valid key is preserved as conflict`() { + val canonicalNum = 1000 + val conflictNum = 2000 + val canonicalKey = ByteArray(32) { 10 }.toByteString() + val conflictKey = ByteArray(32) { 20 }.toByteString() + + // Canonical node with established identity + nodeManager.updateNode(canonicalNum) { makeKnownNode(canonicalNum, canonicalKey, "Canonical") } + + // Conflict node with UNSET hardware but different valid key + nodeManager.updateNode(conflictNum) { + Node( + num = conflictNum, + user = + User( + id = "!conflict", + long_name = "Conflict", + short_name = "CON", + hw_model = HardwareModel.UNSET, + public_key = conflictKey, + ), + publicKey = conflictKey, + ) + } + + // Packet at conflictNum with canonicalKey should be treated as conflict (preserve both) + nodeManager.handleReceivedUser( + conflictNum, + User( + id = "!c", + long_name = "C", + short_name = "C", + hw_model = HardwareModel.TLORA_V2, + public_key = canonicalKey, + ), + ) + + // Both nodes should still exist + assertNotNull(nodeManager.nodeDBbyNodeNum[canonicalNum]) + assertNotNull(nodeManager.nodeDBbyNodeNum[conflictNum]) + assertEquals(canonicalKey, nodeManager.nodeDBbyNodeNum[canonicalNum]!!.publicKey) + assertEquals(conflictKey, nodeManager.nodeDBbyNodeNum[conflictNum]!!.publicKey) + } + + // 15. Custom incomplete identity is preserved + + @Test + fun `custom incomplete identity is preserved over default incoming`() { + val nodeNum = 1234 + val customUser = + User( + id = "!custom", + long_name = "My Custom Name", + short_name = "MCN", + hw_model = HardwareModel.UNSET, // Incomplete hardware + ) + nodeManager.updateNode(nodeNum) { it.copy(user = customUser) } + + // Incoming default user should not overwrite custom identity + val defaultUser = + User( + id = NodeAddress.numToDefaultId(nodeNum), + long_name = "Meshtastic ${nodeNum.toHex().takeLast(4)}", + short_name = nodeNum.toHex().takeLast(4), + hw_model = HardwareModel.UNSET, + ) + nodeManager.handleReceivedUser(nodeNum, defaultUser) + + val result = nodeManager.nodeDBbyNodeNum[nodeNum] + assertEquals("My Custom Name", result!!.user.long_name) + assertEquals("MCN", result.user.short_name) + } + + // 16. Three nodes sharing one user ID + + @Test + fun `three nodes sharing one user ID selects deterministic representative`() { + val userId = "!shared" + val node1 = 1000 + val node2 = 2000 + val node3 = 3000 + + // All three have the same user ID but different node numbers + nodeManager.updateNode(node1) { + Node( + num = node1, + user = User(id = userId, long_name = "N1", short_name = "N1", hw_model = HardwareModel.UNSET), + ) + } + nodeManager.updateNode(node2) { + Node( + num = node2, + user = User(id = userId, long_name = "N2", short_name = "N2", hw_model = HardwareModel.TLORA_V2), + ) + } + nodeManager.updateNode(node3) { + Node( + num = node3, + user = User(id = userId, long_name = "N3", short_name = "N3", hw_model = HardwareModel.UNSET), + ) + } + + // byId should point to node2 (non-placeholder, lowest among non-placeholders) + val representative = nodeManager.getNodeById(userId) + assertNotNull(representative) + assertEquals(node2, representative!!.num) + } + + // 17. Old-ID survivor restoration after put + + @Test + fun `old ID survivor restored after put changes user ID`() { + val nodeNum = 1234 + val oldUserId = "!old" + val newUserId = "!new" + val survivorNum = 5678 + + // Two nodes with old user ID + nodeManager.updateNode(nodeNum) { + Node( + num = nodeNum, + user = User(id = oldUserId, long_name = "Old", short_name = "OLD", hw_model = HardwareModel.TLORA_V2), + ) + } + nodeManager.updateNode(survivorNum) { + Node( + num = survivorNum, + user = + User(id = oldUserId, long_name = "Survivor", short_name = "SUR", hw_model = HardwareModel.TLORA_V2), + ) + } + + // Change nodeNum's user ID + nodeManager.updateNode(nodeNum) { + it.copy(user = it.user.copy(id = newUserId, long_name = "New", short_name = "NEW")) + } + + // byId for oldUserId should now point to survivorNum + val survivor = nodeManager.getNodeById(oldUserId) + assertNotNull(survivor) + assertEquals(survivorNum, survivor!!.num) + + // byId for newUserId should point to nodeNum + val newNode = nodeManager.getNodeById(newUserId) + assertNotNull(newNode) + assertEquals(nodeNum, newNode!!.num) + } + + // 18. fromByNum insertion-order independence + + @Test + fun `fromByNum is independent of insertion order`() { + val userId = "!shared" + val node1 = 1000 + val node2 = 2000 + + val nodes12 = + mapOf( + node1 to + Node( + num = node1, + user = User(id = userId, long_name = "N1", short_name = "N1", hw_model = HardwareModel.UNSET), + ), + node2 to + Node( + num = node2, + user = User( + id = userId, + long_name = "N2", + short_name = "N2", + hw_model = HardwareModel.TLORA_V2, + ), + ), + ) + val nodes21 = + mapOf( + node2 to + Node( + num = node2, + user = User( + id = userId, + long_name = "N2", + short_name = "N2", + hw_model = HardwareModel.TLORA_V2, + ), + ), + node1 to + Node( + num = node1, + user = User(id = userId, long_name = "N1", short_name = "N1", hw_model = HardwareModel.UNSET), + ), + ) + + val index12 = NodeManagerImpl.NodeIndex.fromByNum(nodes12) + val index21 = NodeManagerImpl.NodeIndex.fromByNum(nodes21) + + // Both should produce the same byId representative (node2, non-placeholder) + assertEquals(index12.byId[userId]!!.num, index21.byId[userId]!!.num) + assertEquals(node2, index12.byId[userId]!!.num) + } + + // 19. Preferred canonical mapping after stale removal + + @Test + fun `preferred canonical remains mapped after stale removal`() { + val userId = "!shared" + val canonicalKey = ByteArray(32) { 41 }.toByteString() + val competingKey = ByteArray(32) { 42 }.toByteString() + val preferredNum = canonicalKey.canonicalNum() + val competingNum = 500 + val staleNum = canonicalKey.noncanonicalNum(1000) + + nodeManager.updateNode(competingNum) { + Node( + num = competingNum, + user = + User( + id = userId, + long_name = "Fallback Winner", + short_name = "FBK", + hw_model = HardwareModel.TLORA_V2, + public_key = competingKey, + ), + publicKey = competingKey, + ) + } + nodeManager.updateNode(preferredNum) { + Node( + num = preferredNum, + user = + User( + id = userId, + long_name = "Preferred", + short_name = "PRE", + hw_model = HardwareModel.TLORA_V2, + public_key = canonicalKey, + ), + publicKey = canonicalKey, + ) + } + nodeManager.updateNode(staleNum) { + Node( + num = staleNum, + user = + User( + id = userId, + long_name = "Stale", + short_name = "STL", + hw_model = HardwareModel.TLORA_V2, + public_key = canonicalKey, + ), + publicKey = canonicalKey, + ) + } + + nodeManager.handleReceivedUser( + staleNum, + User( + id = userId, + long_name = "Stale Replay", + short_name = "STL", + hw_model = HardwareModel.TLORA_V2, + public_key = canonicalKey, + ), + ) + + assertNull(nodeManager.nodeDBbyNodeNum[staleNum]) + assertNotNull(nodeManager.nodeDBbyNodeNum[competingNum]) + assertNotNull(nodeManager.nodeDBbyNodeNum[preferredNum]) + val representative = nodeManager.getNodeById(userId) + assertNotNull(representative) + assertEquals(preferredNum, representative!!.num) + } + + // 20. Accepted local node remains the byId representative + + @Test + fun `accepted local node remains byId representative`() { + val userId = "!shared" + val remoteNum = 1000 + val localNum = 2000 + + nodeManager.setMyNodeNum(localNum) + nodeManager.updateNode(localNum) { + Node( + num = localNum, + user = User(id = userId, long_name = "Local", short_name = "LOC", hw_model = HardwareModel.TLORA_V2), + ) + } + nodeManager.updateNode(remoteNum) { + Node( + num = remoteNum, + user = User(id = userId, long_name = "Remote", short_name = "REM", hw_model = HardwareModel.TLORA_V2), + ) + } + + assertEquals(remoteNum, nodeManager.getNodeById(userId)!!.num) + nodeManager.handleReceivedUser( + localNum, + User(id = userId, long_name = "Local Updated", short_name = "LOC", hw_model = HardwareModel.TLORA_V2), + ) + + assertNotNull(nodeManager.nodeDBbyNodeNum[remoteNum]) + val representative = nodeManager.getNodeById(userId) + assertNotNull(representative) + assertEquals(localNum, representative!!.num) + } + + // 21. Exact notification title, message, ID, category, and deep link + + @Test + fun `notification has exact title message ID category and deep link`() { + enableDbWrites() + val nodeNum = 1234 + val user = User(id = "!test", long_name = "Test User", short_name = "TST", hw_model = HardwareModel.TLORA_V2) + + nodeManager.handleReceivedUser(nodeNum, user) + testScope.advanceUntilIdle() + + verifySuspend { + notificationManager.dispatch( + Notification( + title = "New node seen: TST", + message = "Test User", + category = Notification.Category.NodeEvent, + id = nodeNum, + deepLinkUri = "meshtastic://meshtastic/nodes/$nodeNum", + ), + ) + } + } + + // 22. Incoming at the canonical num reconciles in memory only and preserves its placeholder history. + + @Test + fun `incoming canonical replaces own placeholder and removes noncanonical in memory only`() { + val canonicalKey = ByteArray(32) { (it + 7).toByte() }.toByteString() + val canonicalNum = canonicalKey.canonicalNum() + val staleNum = canonicalKey.noncanonicalNum(7777) + // Pre-existing entry at the noncanonical num carries the same key. + nodeManager.updateNode(staleNum) { makeKnownNode(staleNum, canonicalKey, "Ghost") } + // Placeholder at the canonical num so the incoming slot is "open" for reconciliation. + val placeholderId = NodeAddress.numToDefaultId(canonicalNum) + nodeManager.updateNode(canonicalNum) { + Node( + num = canonicalNum, + user = + User( + id = placeholderId, + long_name = "Meshtastic ${placeholderId.takeLast(4)}", + short_name = placeholderId.takeLast(4), + hw_model = HardwareModel.UNSET, + ), + publicKey = ByteString.EMPTY, + position = ProtoPosition(latitude_i = 111, longitude_i = 222), + channel = 4, + ) + } + enableDbWrites() + + nodeManager.handleReceivedUser( + canonicalNum, + User( + id = "!canonical", + long_name = "Canonical", + short_name = "CAN", + hw_model = HardwareModel.TLORA_V2, + public_key = canonicalKey, + ), + ) + testScope.advanceUntilIdle() + + assertNull(nodeManager.nodeDBbyNodeNum[staleNum]) + val canonical = nodeManager.nodeDBbyNodeNum[canonicalNum] + assertNotNull(canonical) + assertEquals("Canonical", canonical!!.user.long_name) + assertEquals(canonicalKey, canonical.publicKey) + assertEquals(111, canonical.position.latitude_i) + assertEquals(222, canonical.position.longitude_i) + assertEquals(4, canonical.channel) + verifyNoRepositoryDeletion() + verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) } + verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) } + } + + // 23. Incoming canonical absent from index entirely creates from packet, removes noncanonical, no notify. + + @Test + fun `incoming canonical absent from index creates from packet removes noncanonical no notify`() { + val canonicalKey = ByteArray(32) { (it + 17).toByte() }.toByteString() + val canonicalNum = canonicalKey.canonicalNum() + val staleNum = canonicalKey.noncanonicalNum(8888) + nodeManager.updateNode(staleNum) { makeKnownNode(staleNum, canonicalKey, "Stale Noncanonical") } + enableDbWrites() + + nodeManager.handleReceivedUser( + canonicalNum, + User( + id = "!fresh", + long_name = "Fresh Canonical", + short_name = "FRS", + hw_model = HardwareModel.TLORA_V2, + public_key = canonicalKey, + ), + ) + testScope.advanceUntilIdle() + + assertNull(nodeManager.nodeDBbyNodeNum[staleNum]) + val canonical = nodeManager.nodeDBbyNodeNum[canonicalNum] + assertNotNull(canonical) + assertEquals("Fresh Canonical", canonical!!.user.long_name) + assertEquals(canonicalKey, canonical.publicKey) + verifyNoRepositoryDeletion() + verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) } + verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) } + } + + // 24. Neither num canonical preserves both with no side effects. + + @Test + fun `neither num canonical preserves both no persistence no notification`() { + val key = ByteArray(32) { (it + 23).toByte() }.toByteString() + // Pick two nums guaranteed distinct from the canonical num and from each other. + val canonicalNum = key.canonicalNum() + val a = if (canonicalNum != 1111) 1111 else 1112 + val b = if (canonicalNum != 2222) 2222 else 2223 + + nodeManager.updateNode(a) { makeKnownNode(a, key, "Alpha") } + nodeManager.updateNode(b) { makeKnownNode(b, key, "Bravo") } + enableDbWrites() + + nodeManager.handleReceivedUser( + a, + User( + id = "!a", + long_name = "Alpha Packet", + short_name = "ALA", + hw_model = HardwareModel.TLORA_V2, + public_key = key, + ), + ) + testScope.advanceUntilIdle() + + // Both are preserved; the incoming packet still updates its own established same-key slot in memory. + val alpha = nodeManager.nodeDBbyNodeNum[a] + val bravo = nodeManager.nodeDBbyNodeNum[b] + assertNotNull(alpha) + assertNotNull(bravo) + assertEquals("Alpha Packet", alpha!!.user.long_name) + assertEquals("Bravo", bravo!!.user.long_name) + verifyNoRepositoryDeletion() + verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) } + verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) } + } + + // 25. Different established valid key at the canonical num is preserved as a conflict. + + @Test + fun `different established key at canonical num preserved as conflict`() { + val canonicalKey = ByteArray(32) { (it + 31).toByte() }.toByteString() + val establishedKey = ByteArray(32) { (it + 32).toByte() }.toByteString() + val canonicalNum = canonicalKey.canonicalNum() + val noncanonicalNum = canonicalKey.noncanonicalNum(3333) + nodeManager.updateNode(noncanonicalNum) { makeKnownNode(noncanonicalNum, canonicalKey, "Noncanonical Holder") } + // Canonical num is occupied by a node with a DIFFERENT valid key. + nodeManager.updateNode(canonicalNum) { makeKnownNode(canonicalNum, establishedKey, "Established") } + enableDbWrites() + + nodeManager.handleReceivedUser( + canonicalNum, + User( + id = "!c", + long_name = "Claimant", + short_name = "CLM", + hw_model = HardwareModel.TLORA_V2, + public_key = canonicalKey, + ), + ) + testScope.advanceUntilIdle() + + // Conflict: both preserved with their original keys; no persistence, no notification. + val atCanonical = nodeManager.nodeDBbyNodeNum[canonicalNum] + val atNoncanonical = nodeManager.nodeDBbyNodeNum[noncanonicalNum] + assertNotNull(atCanonical) + assertNotNull(atNoncanonical) + assertEquals(establishedKey, atCanonical!!.publicKey) + assertEquals("Established", atCanonical.user.long_name) + assertEquals(canonicalKey, atNoncanonical!!.publicKey) + verifyNoRepositoryDeletion() + verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) } + verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) } + } + + // 26. Local classification and the node index share one atomic CAS state. + + @Test + fun `local number change during User reduction retries classification atomically`() { + val key = ByteArray(32) { (it + 37).toByte() }.toByteString() + val previousNum = key.canonicalNum() + val newLocalNum = key.noncanonicalNum(9999) + nodeManager.updateNode(previousNum) { makeKnownNode(previousNum, key, "Previous") } + enableDbWrites() + nodeManager.receivedUserReductionHook = { + nodeManager.receivedUserReductionHook = null + nodeManager.setMyNodeNum(newLocalNum) + } + + nodeManager.handleReceivedUser( + newLocalNum, + User( + id = "!local9999", + long_name = "Current Local", + short_name = "LOC", + hw_model = HardwareModel.TLORA_V2, + public_key = key, + ), + ) + testScope.advanceUntilIdle() + + assertNull(nodeManager.nodeDBbyNodeNum[previousNum]) + assertEquals("Current Local", nodeManager.nodeDBbyNodeNum[newLocalNum]?.user?.long_name) + assertEquals(newLocalNum, nodeManager.myNodeNum.value) + verifyNoRepositoryDeletion() + verifySuspend(mode = VerifyMode.exactly(1)) { nodeRepository.upsert(any()) } + verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) } + } + + @Test + fun `reverse ID and public-key indexes stay consistent across put replacement and remove`() { + val keyA = ByteArray(32) { 11 }.toByteString() + val keyB = ByteArray(32) { 12 }.toByteString() + val sharedId = "!shared" + val replacementId = "!replacement" + var index = NodeManagerImpl.NodeIndex() + index = index.put(1, makeKnownNode(1, keyA).copy(user = makeKnownNode(1, keyA).user.copy(id = sharedId))) + index = index.put(2, makeKnownNode(2, keyA).copy(user = makeKnownNode(2, keyA).user.copy(id = sharedId))) + + assertEquals?>(setOf(1, 2), index.candidateNumsById[sharedId]) + assertEquals?>(setOf(1, 2), index.candidateNumsByPublicKey[keyA]) + assertEquals(1, index.byId[sharedId]?.num) + + val replacement = makeKnownNode(1, keyB).copy(user = makeKnownNode(1, keyB).user.copy(id = replacementId)) + index = index.put(1, replacement) + assertEquals?>(setOf(2), index.candidateNumsById[sharedId]) + assertEquals?>(setOf(1), index.candidateNumsById[replacementId]) + assertEquals?>(setOf(2), index.candidateNumsByPublicKey[keyA]) + assertEquals?>(setOf(1), index.candidateNumsByPublicKey[keyB]) + assertEquals(2, index.byId[sharedId]?.num) + assertEquals(1, index.byId[replacementId]?.num) + + index = index.remove(2) + assertNull(index.candidateNumsById[sharedId]) + assertNull(index.candidateNumsByPublicKey[keyA]) + assertNull(index.byId[sharedId]) + assertEquals(1, index.byId[replacementId]?.num) + } + + private fun Int.toHex(): String = this.toString(16).padStart(8, '0') + + private fun userWithKey(key: ByteString, longName: String, shortName: String): User = User( + id = key.hex(), + long_name = longName, + short_name = shortName, + hw_model = HardwareModel.TLORA_V2, + public_key = key, + ) + + // ---------- Retired-node notification behavior ---------- + + @Test + fun `applyTrustedIdentityMigrations cancels notifications for each retired number`() = testScope.runTest { + // Setup: create nodes with distinct identities + val num1 = 1230588578 + val num2 = 1111111111 + val key1 = ByteArray(32) { 0x01 }.toByteString() + val key2 = ByteArray(32) { 0x02 }.toByteString() + nodeManager.handleReceivedUser(num1, userWithKey(key1, "Node1", "N1"), manuallyVerified = false) + nodeManager.handleReceivedUser(num2, userWithKey(key2, "Node2", "N2"), manuallyVerified = false) + advanceUntilIdle() + + // Act + nodeManager.applyTrustedIdentityMigrations(listOf(num1, num2)) + advanceUntilIdle() + + // Assert + verify(VerifyMode.atLeast(1)) { notificationManager.cancel(num1) } + verify(VerifyMode.atLeast(1)) { notificationManager.cancel(num2) } + } + + @Test + fun `trusted migration commits retirement before notification cancellation`() = testScope.runTest { + val num = 310000000 + val key = ByteArray(32) { 0x12 }.toByteString() + nodeManager.handleReceivedUser(num, userWithKey(key, "Retiring", "RT"), manuallyVerified = false) + advanceUntilIdle() + var nodePresentAtCancellation: Boolean? = null + every { notificationManager.cancel(num) } calls + { + nodePresentAtCancellation = num in nodeManager.nodeDBbyNodeNum + } + + nodeManager.applyTrustedIdentityMigrations(listOf(num)) + + assertEquals(false, nodePresentAtCancellation, "retirement must commit before cancellation side effects") + assertNull(nodeManager.nodeDBbyNodeNum[num]) + verify(VerifyMode.exactly(1)) { notificationManager.cancel(num) } + } + + @Test + fun `notification skipped when number retired between select and dispatch`() = testScope.runTest { + val num = 4000000000.toInt() + val key = ByteArray(32) { 0x03 }.toByteString() + val titleStarted = CompletableDeferred() + val releaseTitle = CompletableDeferred() + val dispatched = mutableListOf() + everySuspend { notificationManager.dispatch(capture(dispatched)) } returns true + nodeManager.notificationTitleFormatter = { shortName -> + titleStarted.complete(Unit) + releaseTitle.await() + "New node seen: $shortName" + } + + nodeManager.handleReceivedUser(num, userWithKey(key, "NewNode", "NN"), manuallyVerified = false) + titleStarted.await() + nodeManager.applyTrustedIdentityMigrations(listOf(num)) + releaseTitle.complete(Unit) + advanceUntilIdle() + + assertTrue(dispatched.none { it.id == num && it.message == "NewNode" }) + } + + @Test + fun `notification skipped when number becomes local node before dispatch`() = testScope.runTest { + val num = 5000000000.toInt() + val key = ByteArray(32) { 0x04 }.toByteString() + val titleStarted = CompletableDeferred() + val releaseTitle = CompletableDeferred() + val dispatched = mutableListOf() + everySuspend { notificationManager.dispatch(capture(dispatched)) } returns true + nodeManager.notificationTitleFormatter = { shortName -> + titleStarted.complete(Unit) + releaseTitle.await() + "New node seen: $shortName" + } + + nodeManager.handleReceivedUser(num, userWithKey(key, "NewNode", "NN"), manuallyVerified = false) + titleStarted.await() + nodeManager.setMyNodeNum(num) + releaseTitle.complete(Unit) + advanceUntilIdle() + + assertTrue(dispatched.none { it.id == num && it.message == "NewNode" }) + } + + @Test + fun `notification skipped when identity changed before dispatch`() = testScope.runTest { + val num = 6000000000.toInt() + val key1 = ByteArray(32) { 0x05 }.toByteString() + val key2 = ByteArray(32) { 0x06 }.toByteString() + val titleStarted = CompletableDeferred() + val releaseTitle = CompletableDeferred() + val dispatched = mutableListOf() + everySuspend { notificationManager.dispatch(capture(dispatched)) } returns true + nodeManager.notificationTitleFormatter = { shortName -> + titleStarted.complete(Unit) + releaseTitle.await() + "New node seen: $shortName" + } + + nodeManager.handleReceivedUser(num, userWithKey(key1, "First", "F1"), manuallyVerified = false) + titleStarted.await() + nodeManager.updateNode(num) { makeKnownNode(num, key2, "Other") } + releaseTitle.complete(Unit) + advanceUntilIdle() + + val node = nodeManager.nodeDBbyNodeNum[num] + assertEquals("Other", node?.user?.long_name) + assertTrue(dispatched.none { it.message == "First" }, "notification for 'First' must be suppressed") + } + + @Test + fun `retired number with same key suppressed even without canonical key candidate`() = testScope.runTest { + val oldNum = 7000000000.toInt() + val key = ByteArray(32) { 0x07 }.toByteString() + // Create node at oldNum with key, then retire it via migration + nodeManager.handleReceivedUser(oldNum, userWithKey(key, "OldNode", "ON"), manuallyVerified = false) + advanceUntilIdle() + nodeManager.applyTrustedIdentityMigrations(listOf(oldNum)) + advanceUntilIdle() + val replayDispatches = mutableListOf() + everySuspend { notificationManager.dispatch(capture(replayDispatches)) } returns true + // Now replay: the canonical number (crc32(key)) has NOT appeared yet + nodeManager.handleReceivedUser(oldNum, userWithKey(key, "Replay", "RP"), manuallyVerified = false) + advanceUntilIdle() + // The replayed node should NOT appear in nodeDBbyNodeNum at oldNum + assertNull(nodeManager.nodeDBbyNodeNum[oldNum]) + assertTrue(replayDispatches.none { it.id == oldNum }) + } + + @Test + fun `keyless retired number rejects an unrepresented valid key for the session`() = testScope.runTest { + val num = 7100000000.toInt() + val incomingKey = ByteArray(32) { 0x17 }.toByteString() + nodeManager.applyTrustedIdentityMigrations(listOf(num)) + + nodeManager.handleReceivedUser( + num, + userWithKey(incomingKey, "Untrusted Reuse", "UR"), + manuallyVerified = false, + ) + advanceUntilIdle() + + assertNull(nodeManager.nodeDBbyNodeNum[num]) + verifySuspend(mode = VerifyMode.exactly(0)) { notificationManager.dispatch(any()) } + } + + @Test + fun `retired number with different valid key can be legitimately reused`() = testScope.runTest { + val num = 8000000000.toInt() + val oldKey = ByteArray(32) { 0x08 }.toByteString() + val newKey = ByteArray(32) { 0x09 }.toByteString() + // Create and retire with old key + nodeManager.handleReceivedUser(num, userWithKey(oldKey, "Old", "OD"), manuallyVerified = false) + advanceUntilIdle() + nodeManager.applyTrustedIdentityMigrations(listOf(num)) + advanceUntilIdle() + // Capture dispatches to verify the reuse notification specifically, + // not the initial sighting from the setup above. + val dispatched = mutableListOf() + everySuspend { notificationManager.dispatch(capture(dispatched)) } returns true + // Replay with different key — should be allowed as legitimate reuse + nodeManager.handleReceivedUser(num, userWithKey(newKey, "New", "NW"), manuallyVerified = false) + advanceUntilIdle() + val newNode = nodeManager.nodeDBbyNodeNum[num] + assertNotNull(newNode) + assertEquals("New", newNode.user.long_name) + assertEquals( + 1, + dispatched.count { it.id == num && it.message == "New" }, + "exactly one notification for the reuse at $num", + ) + } + + @Test + fun `legitimate reuse clears prior retirement hint before a later keyless retirement`() = testScope.runTest { + val num = 8100000000.toInt() + val oldKey = ByteArray(32) { 0x18 }.toByteString() + val reuseKey = ByteArray(32) { 0x19 }.toByteString() + val untrustedKey = ByteArray(32) { 0x1a }.toByteString() + nodeManager.handleReceivedUser(num, userWithKey(oldKey, "Old", "OD"), manuallyVerified = false) + advanceUntilIdle() + nodeManager.applyTrustedIdentityMigrations(listOf(num)) + nodeManager.handleReceivedUser(num, userWithKey(reuseKey, "Reuse", "RE"), manuallyVerified = false) + advanceUntilIdle() + assertEquals("Reuse", nodeManager.nodeDBbyNodeNum[num]?.user?.long_name) + + nodeManager.removeByNodenum(num) + nodeManager.applyTrustedIdentityMigrations(listOf(num)) + nodeManager.handleReceivedUser(num, userWithKey(untrustedKey, "Untrusted", "UN"), manuallyVerified = false) + advanceUntilIdle() + + assertNull(nodeManager.nodeDBbyNodeNum[num], "an absent trusted migration must not retain an obsolete hint") + } + + @Test + fun `public key diagnostics use a one-way fingerprint`() { + val key = ByteArray(32) { it.toByte() }.toByteString() + val fingerprint = publicKeyLogFingerprint(key) + + assertEquals(key.sha256().hex().take(8), fingerprint) + assertFalse(fingerprint == key.hex().take(8), "diagnostics must not expose a raw public-key prefix") + } + + @Test + fun `complete 2_7 to 2_8 renumbering produces no old number notification`() = testScope.runTest { + val oldNum = -297114191 + val newNum = 1230588578 + val key = ByteArray(32) { 0x0a }.toByteString() + // Simulate: old 2.7 local node identity + nodeManager.setMyNodeNum(oldNum) + nodeManager.handleReceivedUser(oldNum, userWithKey(key, "OldLocal", "OL"), manuallyVerified = false) + advanceUntilIdle() + // Trusted migration retires old number, canonical identity moves to newNum + nodeManager.setMyNodeNum(newNum) + nodeManager.handleReceivedUser(newNum, userWithKey(key, "NewLocal", "NL"), manuallyVerified = false) + nodeManager.applyTrustedIdentityMigrations(listOf(oldNum)) + advanceUntilIdle() + // Early replay of old number User packet — should be suppressed + val dispatchedBefore = mutableListOf() + everySuspend { notificationManager.dispatch(capture(dispatchedBefore)) } returns true + nodeManager.handleReceivedUser(oldNum, userWithKey(key, "Replay", "RP"), manuallyVerified = false) + advanceUntilIdle() + // The old number should NOT be in nodeDB + assertNull(nodeManager.nodeDBbyNodeNum[oldNum]) + // No notification should have been dispatched for the old number replay + // (the notification dispatch was captured; check none is for oldNum) + val oldNumNotifications = dispatchedBefore.filter { it.id == oldNum } + assertTrue(oldNumNotifications.isEmpty(), "No notification should be dispatched for retired oldNum") + } + + @Test + fun `genuine remote first sighting emits exactly one notification`() = testScope.runTest { + val num = 9000000000.toInt() + val key = ByteArray(32) { 0x0b }.toByteString() + nodeManager.setMyNodeNum(1230588578) + val dispatched = mutableListOf() + everySuspend { notificationManager.dispatch(capture(dispatched)) } returns true + nodeManager.handleReceivedUser(num, userWithKey(key, "Genuine Remote", "GR"), manuallyVerified = false) + advanceUntilIdle() + val numNotifications = dispatched.filter { it.id == num } + assertEquals(1, numNotifications.size, "Exactly one notification for genuine new node") + } + + // ── Phase 8 — session-safe node cache loading & trusted-hint survival ───────── + + @Test + fun `direct snapshot returns only the newly selected DB after a currentDb switch`() = testScope.runTest { + // Simulate the race that motivated Phase 6: the process-wide nodeDBbyNum StateFlow (a stateIn cache) still + // holds the PREVIOUS database's node map right after a currentDb switch, but the direct snapshot read + // returns + // only the newly selected DB's nodes. + val staleNum = 1001 + val freshNum = 2002 + val staleNode = makeKnownNode(staleNum, validPk, "Old DB") + val freshNode = makeKnownNode(freshNum, validPk, "New DB") + + // Stale StateFlow cache (old DB still reflected) vs. the direct snapshot (new DB only). + every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(staleNum to staleNode)) + everySuspend { nodeRepository.getNodeDbSnapshot() } returns mapOf(freshNum to freshNode) + every { nodeRepository.myNodeInfo } returns MutableStateFlow(null) + + nodeManager.loadCachedNodeDB() + advanceUntilIdle() + + // The cache loader consumed the direct snapshot, so staleNum never entered the live index and freshNum did. + assertNull(nodeManager.nodeDBbyNodeNum[staleNum], "stale-DB row must not leak into the live index") + val loaded = nodeManager.nodeDBbyNodeNum[freshNum] + assertNotNull(loaded) + assertEquals("New DB", loaded!!.user.long_name) + } + + @Test + fun `loadCachedNodeDB result is discarded when clear starts a new session mid-load`() = testScope.runTest { + // Pause the load after the snapshot read so we can race a clear() against it before commit. We use the + // repository snapshot call as the suspension point. + val oldNum = 3003 + val oldNode = makeKnownNode(oldNum, validPk, "Old") + val snapshotRead = CompletableDeferred() + val snapshotRelease = CompletableDeferred() + everySuspend { nodeRepository.getNodeDbSnapshot() } calls + { + snapshotRead.complete(Unit) + snapshotRelease.await() + mapOf(oldNum to oldNode) + } + every { nodeRepository.myNodeInfo } returns MutableStateFlow(null) + + nodeManager.loadCachedNodeDB() + snapshotRead.await() + // While the load is paused after its read, a new session starts. This must discard the old snapshot. + nodeManager.clear() + // Add a node AFTER clear so we can verify the discarded load did not wipe it on commit. + val liveNum = 4004 + nodeManager.updateNode(liveNum) { makeKnownNode(liveNum, validPk, "Live After Clear") } + snapshotRelease.complete(Unit) + advanceUntilIdle() + + // The old-DB snapshot was discarded completely: oldNum is absent, liveNum survives. + assertNull( + nodeManager.nodeDBbyNodeNum[oldNum], + "stale snapshot from the previous session must be discarded", + ) + val live = nodeManager.nodeDBbyNodeNum[liveNum] + assertNotNull(live) + assertEquals("Live After Clear", live!!.user.long_name) + } + + @Test + fun `live node update during the load window is not overwritten by the snapshot`() = testScope.runTest { + val num = 5005 + val staleSnapshotNode = makeKnownNode(num, validPk, "Stale Snapshot").copy(lastHeard = 100) + val liveUpdatedNode = makeKnownNode(num, validPk, "Live Updated").copy(lastHeard = 200) + + val snapshotRead = CompletableDeferred() + val snapshotRelease = CompletableDeferred() + everySuspend { nodeRepository.getNodeDbSnapshot() } calls + { + snapshotRead.complete(Unit) + snapshotRelease.await() + mapOf(num to staleSnapshotNode) + } + every { nodeRepository.myNodeInfo } returns MutableStateFlow(null) + + nodeManager.loadCachedNodeDB() + snapshotRead.await() + // A live update arrives between the snapshot capture and its commit. The live value (with fresher fields) + // must win over the snapshot row at the same num. + nodeManager.updateNode(num) { liveUpdatedNode } + snapshotRelease.complete(Unit) + advanceUntilIdle() + + val result = nodeManager.nodeDBbyNodeNum[num] + assertNotNull(result) + assertEquals("Live Updated", result!!.user.long_name) + assertEquals(200, result.lastHeard, "live mutation field must not be overwritten by the snapshot") + } + + @Test + fun `trusted migration during the load window retires absent number and suppresses replay`() = testScope.runTest { + val num = 6006 + val snapshotNode = makeKnownNode(num, validPk, "Pre-migration") + + val snapshotRead = CompletableDeferred() + val snapshotRelease = CompletableDeferred() + everySuspend { nodeRepository.getNodeDbSnapshot() } calls + { + snapshotRead.complete(Unit) + snapshotRelease.await() + mapOf(num to snapshotNode) + } + every { nodeRepository.myNodeInfo } returns MutableStateFlow(null) + + nodeManager.loadCachedNodeDB() + snapshotRead.await() + // Trusted migration runs while the load is suspended. The migrated number is absent at this point, so its + // hint is captured as "absent" — but the retirement must still survive and suppress a stale replay even + // after + // the load commits. + nodeManager.applyTrustedIdentityMigrations(listOf(num)) + snapshotRelease.complete(Unit) + advanceUntilIdle() + + assertNull( + nodeManager.nodeDBbyNodeNum[num], + "retired number absent at migration time stays retired post-load", + ) + + // Stale same-key replay still suppressed, no notification dispatched for the retired old number. + val dispatched = mutableListOf() + everySuspend { notificationManager.dispatch(capture(dispatched)) } returns true + nodeManager.handleReceivedUser(num, userWithKey(validPk, "Replay", "RP"), manuallyVerified = false) + advanceUntilIdle() + + assertNull(nodeManager.nodeDBbyNodeNum[num]) + assertTrue(dispatched.none { it.id == num }, "no notification for retired-number replay") + } + + @Test + fun `persisted local node fills null identity when live nodes change during load`() = testScope.runTest { + val persistedNum = 7007 + val liveNum = 8008 + val liveKey = ByteArray(32) { (it + 90).toByte() }.toByteString() + val persistedInfo = + MyNodeInfo( + myNodeNum = persistedNum, + hasGPS = false, + model = null, + firmwareVersion = "2.5.0", + couldUpdate = false, + shouldUpdate = false, + currentPacketId = 0L, + messageTimeoutMsec = 0, + minAppVersion = 0, + maxChannels = 0, + hasWifi = false, + channelUtilization = 0f, + airUtilTx = 0f, + deviceId = null, + ) + val snapshotRead = CompletableDeferred() + val snapshotRelease = CompletableDeferred() + everySuspend { nodeRepository.getNodeDbSnapshot() } calls + { + snapshotRead.complete(Unit) + snapshotRelease.await() + mapOf(persistedNum to makeKnownNode(persistedNum, validPk, "Persisted")) + } + every { nodeRepository.myNodeInfo } returns MutableStateFlow(persistedInfo) + + nodeManager.loadCachedNodeDB() + snapshotRead.await() + nodeManager.updateNode(liveNum) { makeKnownNode(liveNum, liveKey, "Live") } + snapshotRelease.complete(Unit) + advanceUntilIdle() + + assertEquals( + persistedNum, + nodeManager.myNodeNum.value, + "the merge path must restore persisted local identity", + ) + assertNotNull(nodeManager.nodeDBbyNodeNum[liveNum], "the concurrent live update must also survive") + } + + @Test + fun `local-node identity learned during the load window survives snapshot commit`() = testScope.runTest { + val persistedNum = 7007 + val persistedInfo = + MyNodeInfo( + myNodeNum = persistedNum, + hasGPS = false, + model = null, + firmwareVersion = "2.5.0", + couldUpdate = false, + shouldUpdate = false, + currentPacketId = 0L, + messageTimeoutMsec = 0, + minAppVersion = 0, + maxChannels = 0, + hasWifi = false, + channelUtilization = 0f, + airUtilTx = 0f, + deviceId = null, + ) + val liveLearnedNum = 8008 + + val snapshotRead = CompletableDeferred() + val snapshotRelease = CompletableDeferred() + everySuspend { nodeRepository.getNodeDbSnapshot() } calls + { + snapshotRead.complete(Unit) + snapshotRelease.await() + // Persisted DB still references the old persistedNum row. + mapOf(persistedNum to makeKnownNode(persistedNum, validPk, "Persisted")) + } + every { nodeRepository.myNodeInfo } returns MutableStateFlow(persistedInfo) + + nodeManager.loadCachedNodeDB() + snapshotRead.await() + // The live session has learned a DIFFERENT local node number during the load window. The persisted value + // must NOT clobber it on commit. + nodeManager.setMyNodeNum(liveLearnedNum) + snapshotRelease.complete(Unit) + advanceUntilIdle() + + assertEquals( + liveLearnedNum, + nodeManager.myNodeNum.value, + "live local-node info wins over persisted snapshot", + ) + } + + @Test + fun `repeated trusted migration preserves original hint and still permits distinct-key reuse`() = + testScope.runTest { + val num = 9009 + val oldKey = ByteArray(32) { 0x21 }.toByteString() + val reuseKey = ByteArray(32) { 0x22 }.toByteString() + nodeManager.handleReceivedUser(num, userWithKey(oldKey, "Old", "OD"), manuallyVerified = false) + advanceUntilIdle() + + // Apply the same migration twice. The original hint must survive the second pass because the node is + // already + // retired and absent; the prior hint is preserved rather than dropped. + nodeManager.applyTrustedIdentityMigrations(listOf(num)) + nodeManager.applyTrustedIdentityMigrations(listOf(num)) + advanceUntilIdle() + + // Same-key replay still suppressed after the repeated migration. + val suppressedDispatches = mutableListOf() + everySuspend { notificationManager.dispatch(capture(suppressedDispatches)) } returns true + nodeManager.handleReceivedUser(num, userWithKey(oldKey, "Replay", "RP"), manuallyVerified = false) + advanceUntilIdle() + assertNull( + nodeManager.nodeDBbyNodeNum[num], + "same-key replay after repeated migration must stay suppressed", + ) + assertTrue(suppressedDispatches.none { it.id == num }) + + // Distinct valid unrepresented key is still accepted as a legitimate reuse, clearing retirement + hint and + // emitting exactly one replacement notification. + val reuseDispatches = mutableListOf() + everySuspend { notificationManager.dispatch(capture(reuseDispatches)) } returns true + nodeManager.handleReceivedUser(num, userWithKey(reuseKey, "Replacement", "NP"), manuallyVerified = false) + advanceUntilIdle() + + val reused = nodeManager.nodeDBbyNodeNum[num] + assertNotNull(reused) + assertEquals("Replacement", reused!!.user.long_name) + assertEquals(reuseKey, reused.publicKey) + assertEquals(1, reuseDispatches.count { it.id == num && it.message == "Replacement" }) + } } diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt index f5d79f40ec..a8d590b197 100644 --- a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt +++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt @@ -286,6 +286,9 @@ class TAKMeshIntegrationTest { override val myId: StateFlow = MutableStateFlow(null) override val localStats: StateFlow = MutableStateFlow(LocalStats()) override val nodeDBbyNum: StateFlow> = MutableStateFlow(emptyMap()) + + override suspend fun getNodeDbSnapshot(): Map = nodeDBbyNum.value + override val onlineNodeCount: Flow = MutableStateFlow(0) override val totalNodeCount: Flow = MutableStateFlow(0)