diff --git a/backend/src/mqtt/client.ts b/backend/src/mqtt/client.ts index 87f4269..6946dca 100644 --- a/backend/src/mqtt/client.ts +++ b/backend/src/mqtt/client.ts @@ -311,6 +311,7 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise { let decodedHash: string | undefined; let decodedHops: number | undefined; let decodedPathHashSizeBytes: number | undefined; + let decodedRouteType: number | undefined; let summary: string | undefined; let srcNodeId: string | undefined; let advertCount: number | undefined; @@ -319,13 +320,14 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise { if (rawHex) { try { - const { decoded, pathHashes, pathHashCount, pathHashSize } = decodePacketCompat(rawHex, keyStore); + const { decoded, pathHashes, pathHashCount, pathHashSize, routeType: decodedRT } = decodePacketCompat(rawHex, keyStore); if (decoded) { resolvedPacketType = decoded.payloadType ?? resolvedPacketType; decodedHash = decoded.messageHash; decodedHops = pathHashCount ?? decoded.pathLength; decodedPathHashSizeBytes = pathHashSize; + decodedRouteType = decodedRT; if (pathHashes && pathHashes.length > 0) { path = pathHashes; } @@ -443,6 +445,7 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise { topic, network, packetType: resolvedPacketType, + routeType: decodedRouteType, hopCount: decodedHops, pathHashSizeBytes: decodedPathHashSizeBytes, direction, @@ -462,7 +465,7 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise { srcNodeId, topic, packetType: resolvedPacketType, - routeType: undefined, + routeType: decodedRouteType, hopCount: decodedHops, pathHashSizeBytes: decodedPathHashSizeBytes, rssi, diff --git a/backend/src/mqtt/decodePacket.ts b/backend/src/mqtt/decodePacket.ts index 83eab82..b6313f1 100644 --- a/backend/src/mqtt/decodePacket.ts +++ b/backend/src/mqtt/decodePacket.ts @@ -3,6 +3,8 @@ import { MeshCoreDecoder } from '@michaelhart/meshcore-decoder'; type KeyStore = ReturnType; type PathMeta = { + routeType: number; + transportCodes: string | undefined; encodedLengthByte: number; pathLengthOffset: number; pathHashCount: number; @@ -16,6 +18,8 @@ export type CompatDecodedPacket = { pathHashes?: string[]; pathHashCount?: number; pathHashSize?: number; + routeType?: number; + transportCodes?: string; }; function bytesToHex(bytes: Uint8Array): string { @@ -34,18 +38,38 @@ function parsePathMeta(rawHex: string): PathMeta | null { const routeType = bytes[0]! & 0x03; let offset = 1; - if (routeType === 0 || routeType === 3) offset += 4; + + // Extract transport codes when present (routeType 0 = FLOOD, 3 = DIRECT with codes) + let transportCodes: string | undefined; + if (routeType === 0 || routeType === 3) { + if (bytes.length < offset + 4) return null; + transportCodes = bytesToHex(bytes.subarray(offset, offset + 4)); + offset += 4; + } + if (bytes.length <= offset) return null; const encodedLengthByte = bytes[offset]!; const pathHashCount = encodedLengthByte & 0x3f; const pathHashSize = (encodedLengthByte >> 6) + 1; - if (pathHashSize > 3) return null; + + // Reserved hash-size mode (0b11 → 4 bytes) — reject fully (#3) + if (pathHashSize > 3) { + console.warn(`[decode] reserved path hash size mode ${pathHashSize} in packet ${rawHex.slice(0, 16)}…`); + return null; + } const pathByteLength = pathHashCount * pathHashSize; const pathStart = offset + 1; const pathEnd = pathStart + pathByteLength; - if (bytes.length < pathEnd) return null; + + // Warn on truncated path data (#2) + if (bytes.length < pathEnd) { + console.warn( + `[decode] truncated path: need ${pathByteLength} path bytes but only ${bytes.length - pathStart} available in packet ${rawHex.slice(0, 16)}…`, + ); + return null; + } const pathHashes: string[] = []; for (let i = 0; i < pathHashCount; i++) { @@ -55,6 +79,8 @@ function parsePathMeta(rawHex: string): PathMeta | null { } return { + routeType, + transportCodes, encodedLengthByte, pathLengthOffset: offset, pathHashCount, @@ -66,6 +92,15 @@ function parsePathMeta(rawHex: string): PathMeta | null { function buildCompatRawHex(rawHex: string, meta: PathMeta): string { if (meta.encodedLengthByte < 64) return rawHex; + + // Guard: pathByteLength > 63 would bleed into upper 2 bits, corrupting the compat byte (#6) + if (meta.pathByteLength > 63) { + console.warn( + `[decode] pathByteLength ${meta.pathByteLength} exceeds 63, skipping compat rewrite for packet ${rawHex.slice(0, 16)}…`, + ); + return rawHex; + } + const bytes = hexToBytes(rawHex); if (!bytes) return rawHex; const compat = new Uint8Array(bytes); @@ -82,5 +117,7 @@ export function decodePacketCompat(rawHex: string, keyStore: KeyStore): CompatDe pathHashes: meta?.pathHashes ?? (decoded.path as string[] | null) ?? undefined, pathHashCount: meta?.pathHashCount ?? decoded.pathLength ?? undefined, pathHashSize: meta?.pathHashSize ?? 1, + routeType: meta?.routeType ?? decoded.routeType ?? undefined, + transportCodes: meta?.transportCodes, }; } diff --git a/backend/src/path-beta/resolver.ts b/backend/src/path-beta/resolver.ts index 4e6b04c..30ffde5 100644 --- a/backend/src/path-beta/resolver.ts +++ b/backend/src/path-beta/resolver.ts @@ -1038,7 +1038,7 @@ export type BetaResolvedPayload = { export async function resolveBetaPathForPacketHash(packetHash: string, network: string, observer?: string): Promise { const [packetResult, observerHopResult] = await Promise.all([ query( - `SELECT packet_hash, rx_node_id, src_node_id, packet_type, hop_count, path_hashes + `SELECT packet_hash, rx_node_id, src_node_id, packet_type, hop_count, path_hashes, path_hash_size_bytes FROM packets WHERE packet_hash = $1 AND ($2 = 'all' OR network = $2) @@ -1095,7 +1095,20 @@ export async function resolveBetaPathForPacketHash(packetHash: string, network: const src = packet.src_node_id ? (context.nodesById.get(packet.src_node_id) ?? null) : null; const hashes = packet.path_hashes ?? []; - const hops = packet.hop_count != null ? hashes.slice(0, Math.max(0, packet.hop_count)) : hashes; + + // Validate path hash lengths against wire-format hash size when available (#4) + const expectedHexLen = packet.path_hash_size_bytes != null ? packet.path_hash_size_bytes * 2 : null; + const validatedHashes = expectedHexLen != null + ? hashes.filter((h) => { + if (h.length !== expectedHexLen) { + console.warn(`${logPrefix} hash length mismatch: expected ${expectedHexLen} hex chars, got ${h.length} ("${h}")`); + return false; + } + return true; + }) + : hashes; + + const hops = packet.hop_count != null ? validatedHashes.slice(0, Math.max(0, packet.hop_count)) : validatedHashes; const forceIncludeSource = packet.packet_type === 4; const currentHopCount = Number(packet.hop_count ?? 0); const observerHopHints: ObserverHopHint[] = currentHopCount > 0 && packet.rx_node_id diff --git a/backend/src/path-beta/types.ts b/backend/src/path-beta/types.ts index 9373e3f..942d8c5 100644 --- a/backend/src/path-beta/types.ts +++ b/backend/src/path-beta/types.ts @@ -37,6 +37,7 @@ export type PathPacket = { packet_type: number | null; hop_count: number | null; path_hashes: string[] | null; + path_hash_size_bytes: number | null; }; export type ObserverHopHint = { diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts index 443ccbf..3c683c3 100644 --- a/backend/src/types/index.ts +++ b/backend/src/types/index.ts @@ -56,6 +56,7 @@ export interface LivePacket { topic: string; network?: string; packetType?: number; + routeType?: number; // 0=FLOOD, 1=DIRECT, 2=FLOOD+codes, 3=DIRECT+codes hopCount?: number; pathHashSizeBytes?: number; direction?: string; // 'rx' | 'tx' from mctomqtt