From 14367488e2deaf4b4e7b73233a3502a233b943b7 Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Mon, 13 Apr 2026 08:20:09 -0700 Subject: [PATCH] fix: TRACE path_json uses path_sz from flags byte, not header hash_size (#732) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary TRACE packets encode their route hash size in the flags byte (`flags & 0x03`), not the header path byte. The decoder was using `path.HashSize` from the header, which could be wrong or zero for direct-route TRACEs, producing incorrect hop counts in `path_json`. ## Protocol Note Per firmware, TRACE packets are **always direct-routed** (route_type 2 = DIRECT, or 3 = TRANSPORT_DIRECT). FLOOD-routed TRACEs (route_type 1) are anomalous — firmware explicitly rejects TRACE via flood. The decoder handles these gracefully without crashing. ## Changes **`cmd/server/decoder.go` and `cmd/ingestor/decoder.go`:** - Read `pathSz` from TRACE flags byte: `(traceFlags & 0x03) + 1` (0→1byte, 1→2byte, 2→3byte) - Use `pathSz` instead of `path.HashSize` for splitting TRACE payload path data into hops - Update `path.HashSize` to reflect the actual TRACE path size - Added `HopsCompleted` field to ingestor `Path` struct for parity with server - Updated comments to clarify TRACE is always direct-routed per firmware **`cmd/server/decoder_test.go` — 5 new tests:** - `TraceFlags1_TwoBytePathSz`: flags=1 → 2-byte hashes via DIRECT route - `TraceFlags2_ThreeBytePathSz`: flags=2 → 3-byte hashes via DIRECT route - `TracePathSzUnevenPayload`: payload not evenly divisible by path_sz - `TraceTransportDirect`: route_type=3 with transport codes + TRACE path parsing - `TraceFloodRouteGraceful`: anomalous FLOOD+TRACE handled without crash All existing TRACE tests (flags=0, 1-byte hashes) continue to pass. Fixes #731 --------- Co-authored-by: you --- cmd/ingestor/decoder.go | 39 +++++++++--- cmd/server/decoder.go | 28 ++++++--- cmd/server/decoder_test.go | 122 +++++++++++++++++++++++++++++++++++++ public/live.js | 3 +- public/packets.js | 14 +++++ test-frontend-helpers.js | 36 +++++++++++ 6 files changed, 225 insertions(+), 17 deletions(-) diff --git a/cmd/ingestor/decoder.go b/cmd/ingestor/decoder.go index c7c8d06f..796cff8b 100644 --- a/cmd/ingestor/decoder.go +++ b/cmd/ingestor/decoder.go @@ -80,9 +80,10 @@ type TransportCodes struct { // Path holds decoded path/hop information. type Path struct { - HashSize int `json:"hashSize"` - HashCount int `json:"hashCount"` - Hops []string `json:"hops"` + HashSize int `json:"hashSize"` + HashCount int `json:"hashCount"` + Hops []string `json:"hops"` + HopsCompleted *int `json:"hopsCompleted,omitempty"` } // AdvertFlags holds decoded advert flag bits. @@ -143,6 +144,7 @@ type DecodedPacket struct { Path Path `json:"path"` Payload Payload `json:"payload"` Raw string `json:"raw"` + Anomaly string `json:"anomaly,omitempty"` } func decodeHeader(b byte) Header { @@ -586,17 +588,35 @@ func DecodePacket(hexString string, channelKeys map[string]string, validateSigna payload := decodePayload(header.PayloadType, payloadBuf, channelKeys, validateSignatures) // TRACE packets store hop IDs in the payload (buf[9:]) rather than the header - // path field. The header path byte still encodes hashSize in bits 6-7, which - // we use to split the payload path data into individual hop prefixes. + // path field. Firmware always sends TRACE as DIRECT (route_type 2 or 3); + // FLOOD-routed TRACEs are anomalous but handled gracefully (parsed, but + // flagged). The TRACE flags byte (payload offset 8) encodes path_sz in + // bits 0-1 as a power-of-two exponent: hash_bytes = 1 << path_sz. + // NOT the header path byte's hash_size bits. The header path contains SNR + // bytes — one per hop that actually forwarded. + // We expose hopsCompleted (count of SNR bytes) so consumers can distinguish + // how far the trace got vs the full intended route. + var anomaly string if header.PayloadType == PayloadTRACE && payload.PathData != "" { + // Flag anomalous routing — firmware only sends TRACE as DIRECT + if header.RouteType != RouteDirect && header.RouteType != RouteTransportDirect { + anomaly = "TRACE packet with non-DIRECT routing (expected DIRECT or TRANSPORT_DIRECT)" + } + // The header path hops count represents SNR entries = completed hops + hopsCompleted := path.HashCount pathBytes, err := hex.DecodeString(payload.PathData) - if err == nil && path.HashSize > 0 { - hops := make([]string, 0, len(pathBytes)/path.HashSize) - for i := 0; i+path.HashSize <= len(pathBytes); i += path.HashSize { - hops = append(hops, strings.ToUpper(hex.EncodeToString(pathBytes[i:i+path.HashSize]))) + if err == nil && payload.TraceFlags != nil { + // path_sz from flags byte is a power-of-two exponent per firmware: + // hash_bytes = 1 << (flags & 0x03) + pathSz := 1 << (*payload.TraceFlags & 0x03) + hops := make([]string, 0, len(pathBytes)/pathSz) + for i := 0; i+pathSz <= len(pathBytes); i += pathSz { + hops = append(hops, strings.ToUpper(hex.EncodeToString(pathBytes[i:i+pathSz]))) } path.Hops = hops path.HashCount = len(hops) + path.HashSize = pathSz + path.HopsCompleted = &hopsCompleted } } @@ -616,6 +636,7 @@ func DecodePacket(hexString string, channelKeys map[string]string, validateSigna Path: path, Payload: payload, Raw: strings.ToUpper(hexString), + Anomaly: anomaly, }, nil } diff --git a/cmd/server/decoder.go b/cmd/server/decoder.go index 4e8e1539..beb7b4a9 100644 --- a/cmd/server/decoder.go +++ b/cmd/server/decoder.go @@ -116,6 +116,7 @@ type DecodedPacket struct { Path Path `json:"path"` Payload Payload `json:"payload"` Raw string `json:"raw"` + Anomaly string `json:"anomaly,omitempty"` } func decodeHeader(b byte) Header { @@ -388,22 +389,34 @@ func DecodePacket(hexString string, validateSignatures bool) (*DecodedPacket, er payload := decodePayload(header.PayloadType, payloadBuf, validateSignatures) // TRACE packets store hop IDs in the payload (buf[9:]) rather than the header - // path field. The header path byte still encodes hashSize in bits 6-7, which - // we use to split the payload path data into individual hop prefixes. - // The header path contains SNR bytes — one per hop that actually forwarded. + // path field. Firmware always sends TRACE as DIRECT (route_type 2 or 3); + // FLOOD-routed TRACEs are anomalous but handled gracefully (parsed, but + // flagged). The TRACE flags byte (payload offset 8) encodes path_sz in + // bits 0-1 as a power-of-two exponent: hash_bytes = 1 << path_sz. + // NOT the header path byte's hash_size bits. The header path contains SNR + // bytes — one per hop that actually forwarded. // We expose hopsCompleted (count of SNR bytes) so consumers can distinguish // how far the trace got vs the full intended route. + var anomaly string if header.PayloadType == PayloadTRACE && payload.PathData != "" { + // Flag anomalous routing — firmware only sends TRACE as DIRECT + if header.RouteType != RouteDirect && header.RouteType != RouteTransportDirect { + anomaly = "TRACE packet with non-DIRECT routing (expected DIRECT or TRANSPORT_DIRECT)" + } // The header path hops count represents SNR entries = completed hops hopsCompleted := path.HashCount pathBytes, err := hex.DecodeString(payload.PathData) - if err == nil && path.HashSize > 0 { - hops := make([]string, 0, len(pathBytes)/path.HashSize) - for i := 0; i+path.HashSize <= len(pathBytes); i += path.HashSize { - hops = append(hops, strings.ToUpper(hex.EncodeToString(pathBytes[i:i+path.HashSize]))) + if err == nil && payload.TraceFlags != nil { + // path_sz from flags byte is a power-of-two exponent per firmware: + // hash_bytes = 1 << (flags & 0x03) + pathSz := 1 << (*payload.TraceFlags & 0x03) + hops := make([]string, 0, len(pathBytes)/pathSz) + for i := 0; i+pathSz <= len(pathBytes); i += pathSz { + hops = append(hops, strings.ToUpper(hex.EncodeToString(pathBytes[i:i+pathSz]))) } path.Hops = hops path.HashCount = len(hops) + path.HashSize = pathSz path.HopsCompleted = &hopsCompleted } } @@ -424,6 +437,7 @@ func DecodePacket(hexString string, validateSignatures bool) (*DecodedPacket, er Path: path, Payload: payload, Raw: strings.ToUpper(hexString), + Anomaly: anomaly, }, nil } diff --git a/cmd/server/decoder_test.go b/cmd/server/decoder_test.go index 3d72c631..f5012075 100644 --- a/cmd/server/decoder_test.go +++ b/cmd/server/decoder_test.go @@ -357,6 +357,10 @@ func TestDecodePacket_TraceHopsCompleted(t *testing.T) { if *pkt.Path.HopsCompleted != 2 { t.Errorf("expected HopsCompleted=2, got %d", *pkt.Path.HopsCompleted) } + // FLOOD routing for TRACE is anomalous + if pkt.Anomaly == "" { + t.Error("expected anomaly flag for FLOOD-routed TRACE") + } } func TestDecodePacket_TraceNoSNR(t *testing.T) { @@ -407,6 +411,124 @@ func TestDecodePacket_TraceFullyCompleted(t *testing.T) { } } +func TestDecodePacket_TraceFlags1_TwoBytePathSz(t *testing.T) { + // TRACE with flags=1 → path_sz = 1 << (1 & 0x03) = 2-byte hashes + // Firmware always sends TRACE as DIRECT (route_type=2), so header byte = + // (0<<6)|(9<<2)|2 = 0x26. path_length 0x00 = 0 SNR bytes. + hex := "2600" + // header (DIRECT+TRACE) + path_length (0 SNR) + "01000000" + // tag + "02000000" + // authCode + "01" + // flags = 1 → path_sz = 2 + "AABBCCDD" // 4 bytes = 2 hops of 2-byte each + + pkt, err := DecodePacket(hex, false) + if err != nil { + t.Fatalf("DecodePacket error: %v", err) + } + if len(pkt.Path.Hops) != 2 { + t.Errorf("expected 2 hops (2-byte path_sz), got %d: %v", len(pkt.Path.Hops), pkt.Path.Hops) + } + if pkt.Path.HashSize != 2 { + t.Errorf("expected HashSize=2, got %d", pkt.Path.HashSize) + } + if pkt.Anomaly != "" { + t.Errorf("expected no anomaly for DIRECT TRACE, got %q", pkt.Anomaly) + } +} + +func TestDecodePacket_TraceFlags2_FourBytePathSz(t *testing.T) { + // TRACE with flags=2 → path_sz = 1 << (2 & 0x03) = 4-byte hashes + // DIRECT route_type (0x26) + hex := "2600" + // header (DIRECT+TRACE) + path_length (0 SNR) + "01000000" + // tag + "02000000" + // authCode + "02" + // flags = 2 → path_sz = 4 + "AABBCCDD11223344" // 8 bytes = 2 hops of 4-byte each + + pkt, err := DecodePacket(hex, false) + if err != nil { + t.Fatalf("DecodePacket error: %v", err) + } + if len(pkt.Path.Hops) != 2 { + t.Errorf("expected 2 hops (4-byte path_sz), got %d: %v", len(pkt.Path.Hops), pkt.Path.Hops) + } + if pkt.Path.HashSize != 4 { + t.Errorf("expected HashSize=4, got %d", pkt.Path.HashSize) + } +} + +func TestDecodePacket_TracePathSzUnevenPayload(t *testing.T) { + // TRACE with flags=1 → path_sz=2, but 5 bytes of path data (not evenly divisible) + // Should produce 2 hops (4 bytes) and ignore the trailing byte + hex := "2600" + // header (DIRECT+TRACE) + path_length (0 SNR) + "01000000" + // tag + "02000000" + // authCode + "01" + // flags = 1 → path_sz = 2 + "AABBCCDDEE" // 5 bytes → 2 hops, 1 byte remainder ignored + + pkt, err := DecodePacket(hex, false) + if err != nil { + t.Fatalf("DecodePacket error: %v", err) + } + if len(pkt.Path.Hops) != 2 { + t.Errorf("expected 2 hops (trailing byte ignored), got %d: %v", len(pkt.Path.Hops), pkt.Path.Hops) + } +} + +func TestDecodePacket_TraceTransportDirect(t *testing.T) { + // TRACE via TRANSPORT_DIRECT (route_type=3) — includes 4 transport code bytes + // header: (0<<6)|(9<<2)|3 = 0x27 + hex := "27" + // header (TRANSPORT_DIRECT+TRACE) + "AABB" + "CCDD" + // transport codes (2+2 bytes) + "02" + // path_length: hash_count=2 SNR bytes + "EEFF" + // 2 SNR bytes + "01000000" + // tag + "02000000" + // authCode + "00" + // flags = 0 → path_sz = 1 + "112233" // 3 hops (1-byte each) + + pkt, err := DecodePacket(hex, false) + if err != nil { + t.Fatalf("DecodePacket error: %v", err) + } + if pkt.TransportCodes == nil { + t.Fatal("expected transport codes for TRANSPORT_DIRECT") + } + if pkt.TransportCodes.Code1 != "AABB" { + t.Errorf("expected Code1=AABB, got %s", pkt.TransportCodes.Code1) + } + if len(pkt.Path.Hops) != 3 { + t.Errorf("expected 3 hops, got %d: %v", len(pkt.Path.Hops), pkt.Path.Hops) + } + if pkt.Path.HopsCompleted == nil || *pkt.Path.HopsCompleted != 2 { + t.Errorf("expected HopsCompleted=2, got %v", pkt.Path.HopsCompleted) + } + if pkt.Anomaly != "" { + t.Errorf("expected no anomaly for TRANSPORT_DIRECT TRACE, got %q", pkt.Anomaly) + } +} + +func TestDecodePacket_TraceFloodRouteAnomaly(t *testing.T) { + // TRACE via FLOOD (route_type=1) — anomalous per firmware (firmware only + // sends TRACE as DIRECT). Should still parse but flag the anomaly. + hex := "2500" + // header (FLOOD+TRACE) + path_length (0 SNR) + "01000000" + // tag + "02000000" + // authCode + "01" + // flags = 1 → path_sz = 2 + "AABBCCDD" // 4 bytes = 2 hops of 2-byte each + + pkt, err := DecodePacket(hex, false) + if err != nil { + t.Fatalf("should not crash on anomalous FLOOD+TRACE: %v", err) + } + if len(pkt.Path.Hops) != 2 { + t.Errorf("expected 2 hops even for anomalous FLOOD route, got %d", len(pkt.Path.Hops)) + } + if pkt.Anomaly == "" { + t.Error("expected anomaly flag for FLOOD-routed TRACE, got empty string") + } +} + func TestDecodeAdvertSignatureValidation(t *testing.T) { pub, priv, err := ed25519.GenerateKey(nil) if err != nil { diff --git a/public/live.js b/public/live.js index c68038ac..0819c51d 100644 --- a/public/live.js +++ b/public/live.js @@ -2730,6 +2730,7 @@ const preview = text ? ' ' + (text.length > 35 ? text.slice(0, 35) + '…' : text) : ''; const hopStr = hops.length ? `${hops.length}⇢` : ''; const obsBadge = pkt.observation_count > 1 ? `👁 ${pkt.observation_count}` : ''; + const anomalyIcon = (pkt.decoded && pkt.decoded.anomaly) ? '⚠️' : ''; var _ccPayload2 = (pkt.decoded || {}).payload || {}; var _ccChan = (typeName === 'GRP_TXT' || typeName === 'CHAN') ? (_ccPayload2.channel || null) : null; var dotHtml = _ccChan ? _feedColorDot(_ccChan) : ''; @@ -2744,7 +2745,7 @@ item.innerHTML = ` ${icon} ${typeName} - ${dotHtml}${transportBadge(pkt.route_type)}${hopStr}${obsBadge} + ${dotHtml}${transportBadge(pkt.route_type)}${hopStr}${obsBadge}${anomalyIcon} ${escapeHtml(preview)} ${formatLiveTimestampHtml(pkt._ts || Date.now())} `; diff --git a/public/packets.js b/public/packets.js index e22fc91e..4381fc14 100644 --- a/public/packets.js +++ b/public/packets.js @@ -1849,7 +1849,12 @@ } } + const anomalyBanner = decoded.anomaly + ? `
⚠️ Anomaly: ${escapeHtml(decoded.anomaly)}
` + : ''; + panel.innerHTML = ` + ${anomalyBanner}
${hasRawHex ? `Packet Byte Breakdown (${size} bytes)` : typeName + ' Packet'}
${pkt.hash || 'Packet #' + pkt.id}
${messageHtml} @@ -2053,6 +2058,10 @@ rows += fieldRow(off, 'Raw', truncate(buf.slice(off * 2), 40), ''); } + if (decoded.anomaly) { + rows += `⚠️ Anomaly${escapeHtml(decoded.anomaly)}`; + } + return `${rows} @@ -2144,6 +2153,11 @@ let html = '
'; + // Anomaly banner + if (d.anomaly) { + html += '
⚠️ Anomaly: ' + escapeHtml(d.anomaly) + '
'; + } + // Header section html += '
' + '
Header
' diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index 4c6d9449..ba128337 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -5079,6 +5079,42 @@ console.log('\n=== analytics.js: renderMultiByteAdopters ==='); } } +// ===== packets.js: anomaly banner rendering ===== +console.log('\n=== packets.js: anomaly UI rendering ==='); +{ + const packetsSource = fs.readFileSync('public/packets.js', 'utf8'); + + test('renderDetail shows anomaly banner when decoded.anomaly is set', () => { + assert.ok(packetsSource.includes('anomaly-banner'), + 'packets.js should contain anomaly-banner class'); + assert.ok(packetsSource.includes("decoded.anomaly"), + 'packets.js should reference decoded.anomaly'); + }); + + test('buildFieldTable includes anomaly row when present', () => { + assert.ok(packetsSource.includes('anomaly-row'), + 'buildFieldTable should have anomaly-row class for highlighted row'); + }); + + test('renderDecodedPacket shows anomaly banner', () => { + assert.ok(packetsSource.includes("d.anomaly"), + 'renderDecodedPacket should check d.anomaly'); + }); +} + +// ===== live.js: anomaly icon in feed ===== +console.log('\n=== live.js: anomaly icon in feed ==='); +{ + const liveSource = fs.readFileSync('public/live.js', 'utf8'); + + test('addFeedItemDOM shows anomaly icon when decoded has anomaly', () => { + assert.ok(liveSource.includes('anomalyIcon'), + 'live.js should have anomalyIcon variable for feed items'); + assert.ok(liveSource.includes('pkt.decoded && pkt.decoded.anomaly'), + 'live.js should check pkt.decoded.anomaly'); + }); +} + // ===== SUMMARY ===== Promise.allSettled(pendingTests).then(() => { console.log(`\n${'═'.repeat(40)}`);
OffsetFieldValueDescription