diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 472896d..71e57f3 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -3470,10 +3470,14 @@ class MeshCoreConnector extends ChangeNotifier { // Update any in-flight retries so they use the new path override _retryService?.updatePendingContact(_contacts[index]); - // If setting a specific path (not flood, not auto), also sync with device + // If setting a specific path (not flood, not auto), also sync with device. + // pathLen from the override dialog is a BYTE count; convert to a true hop + // count at the contact's width so the wire path_len packs correctly (#279). if (pathLen != null && pathLen >= 0 && pathBytes != null) { + final w = contact.pathHashWidth < 1 ? 1 : contact.pathHashWidth; + final hops = w > 0 ? pathBytes.length ~/ w : pathBytes.length; appLogger.info('Sending path to device...', tag: 'Connector'); - await setContactPath(contact, pathBytes, pathLen); + await setContactPath(contact, pathBytes, hops, hashWidth: w); appLogger.info('Path sent to device', tag: 'Connector'); } @@ -3505,6 +3509,7 @@ class MeshCoreConnector extends ChangeNotifier { contact, Uint8List.fromList(resolved.pathBytes), resolved.hopCount, + hashWidth: resolved.hashWidth, ); } diff --git a/lib/models/path_selection.dart b/lib/models/path_selection.dart index cdb3d72..9157e41 100644 --- a/lib/models/path_selection.dart +++ b/lib/models/path_selection.dart @@ -6,28 +6,52 @@ const int recentAttemptDiversityWindow = 2; class PathSelection { final List pathBytes; + + /// TRUE hop count (number of hops), or -1 for flood. NOT a byte count. + /// `pathBytes.length == hopCount * hashWidth` for a routed selection. final int hopCount; + + /// Bytes per hop hash (1..3). Defaults to 1 (legacy single-byte). Sent on the + /// wire packed with the hop count via `encodePathLen`. Without this, routed + /// sends went out at width 1 and a 2-byte route was read as twice as many + /// 1-byte hops, routing to the wrong nodes (#279). + final int hashWidth; + final bool useFlood; const PathSelection({ required this.pathBytes, required this.hopCount, + this.hashWidth = 1, required this.useFlood, }); } +/// Resolves the path to send to [contact], as a (bytes, true-hop-count, width) +/// triple. The contact's own `pathHashWidth` is the single width authority for +/// every non-flood branch, so an override, a device path, and a path-history +/// retry all encode at the width the route was actually captured at. PathSelection resolvePathSelection( Contact contact, { PathSelection? selection, bool forceFlood = false, }) { + final width = contact.pathHashWidth < 1 ? 1 : contact.pathHashWidth; + + // Hops for a byte array at [width]; integer division tolerates a malformed + // length rather than throwing. + int hopsFor(List bytes) => + width > 0 ? bytes.length ~/ width : bytes.length; + if (contact.pathOverride != null) { if (contact.pathOverride! < 0) { return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true); } + final bytes = contact.pathOverrideBytes ?? Uint8List(0); return PathSelection( - pathBytes: contact.pathOverrideBytes ?? Uint8List(0), - hopCount: contact.pathOverride!, + pathBytes: bytes, + hopCount: hopsFor(bytes), + hashWidth: width, useFlood: false, ); } @@ -36,10 +60,13 @@ PathSelection resolvePathSelection( return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true); } + // Path-history retry: reuse the bytes, but derive hops/width from THIS + // contact so the wire encoding matches the contact's configured width. if (selection != null && selection.pathBytes.isNotEmpty) { return PathSelection( pathBytes: selection.pathBytes, - hopCount: selection.hopCount, + hopCount: hopsFor(selection.pathBytes), + hashWidth: width, useFlood: false, ); } @@ -47,6 +74,7 @@ PathSelection resolvePathSelection( return PathSelection( pathBytes: contact.path, hopCount: contact.pathLength, + hashWidth: width, useFlood: false, ); } diff --git a/test/models/path_selection_test.dart b/test/models/path_selection_test.dart new file mode 100644 index 0000000..b3ffbbc --- /dev/null +++ b/test/models/path_selection_test.dart @@ -0,0 +1,119 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/connector/meshcore_protocol.dart'; +import 'package:meshcore_open/models/contact.dart'; +import 'package:meshcore_open/models/path_selection.dart'; + +Contact _contact({ + required int pathLength, + required int pathHashWidth, + List path = const [], + int? pathOverride, + List? pathOverrideBytes, +}) { + return Contact( + publicKey: Uint8List(32), + name: 'R', + type: advTypeRepeater, + pathLength: pathLength, + pathHashWidth: pathHashWidth, + path: Uint8List.fromList(path), + pathOverride: pathOverride, + pathOverrideBytes: pathOverrideBytes == null + ? null + : Uint8List.fromList(pathOverrideBytes), + lastSeen: DateTime.utc(2026), + ); +} + +void main() { + const pathLenOffset = 35; // 1 cmd + 32 pubKey + 1 type + 1 flags + + group('resolvePathSelection width (#279)', () { + test('device path reports true hops + the captured width', () { + // Bandit 2026-08-01: a 6-hop 2-byte route (12 path bytes). It must NOT be + // reported as 12 hops nor sent at width 1. + final bytes = [ + 0xC6, 0x5C, 0x64, 0x7A, 0x75, 0xC9, // + 0x73, 0x60, 0xF6, 0x9F, 0xFB, 0x97, + ]; + final r = resolvePathSelection( + _contact(pathLength: 6, pathHashWidth: 2, path: bytes), + ); + expect(r.useFlood, isFalse); + expect(r.hopCount, 6); + expect(r.hashWidth, 2); + expect(r.pathBytes.length, 12); + }); + + test('end to end: the device path encodes as 0x46, not 0x06', () { + final bytes = [ + 0xC6, 0x5C, 0x64, 0x7A, 0x75, 0xC9, // + 0x73, 0x60, 0xF6, 0x9F, 0xFB, 0x97, + ]; + final r = resolvePathSelection( + _contact(pathLength: 6, pathHashWidth: 2, path: bytes), + ); + final frame = buildUpdateContactPathFrame( + Uint8List(32), + Uint8List.fromList(r.pathBytes), + r.hopCount, + hashWidth: r.hashWidth, + ); + expect(frame[pathLenOffset], 0x46); + expect(pathHopCount(frame[pathLenOffset]), 6); + expect(pathHashSizeBytes(frame[pathLenOffset]), 2); + }); + + test('override: one 2-byte hop is 1 hop at width 2, not 2 hops', () { + // The dialog stores pathOverride as a BYTE count (2); the selection must + // still resolve to a single 2-byte hop. + final r = resolvePathSelection( + _contact( + pathLength: 1, + pathHashWidth: 2, + pathOverride: 2, + pathOverrideBytes: [0xC6, 0x5C], + ), + ); + expect(r.hopCount, 1); + expect(r.hashWidth, 2); + }); + + test('legacy 1-byte net is unchanged', () { + final r = resolvePathSelection( + _contact(pathLength: 3, pathHashWidth: 1, path: [0xAA, 0xBB, 0xCC]), + ); + expect(r.hopCount, 3); + expect(r.hashWidth, 1); + }); + + test('flood override passes through as flood', () { + final r = resolvePathSelection( + _contact(pathLength: 0, pathHashWidth: 2, pathOverride: -1), + ); + expect(r.useFlood, isTrue); + expect(r.hopCount, -1); + }); + + test('path-history retry derives width from the contact', () { + final retry = const PathSelection( + pathBytes: [0xC6, 0x5C, 0xA1, 0xB2], + hopCount: 99, // stale/ambiguous; must be recomputed + useFlood: false, + ); + final r = resolvePathSelection( + _contact( + pathLength: 2, + pathHashWidth: 2, + path: [0x00, 0x00, 0x00, 0x00], + ), + selection: retry, + ); + expect(r.pathBytes.length, 4); + expect(r.hopCount, 2); + expect(r.hashWidth, 2); + }); + }); +}