diff --git a/cmd/server/decoder.go b/cmd/server/decoder.go index b902930a..d9dc77f6 100644 --- a/cmd/server/decoder.go +++ b/cmd/server/decoder.go @@ -397,6 +397,106 @@ func DecodePacket(hexString string) (*DecodedPacket, error) { }, nil } +// HexRange represents a labeled byte range for the hex breakdown visualization. +type HexRange struct { + Start int `json:"start"` + End int `json:"end"` + Label string `json:"label"` +} + +// Breakdown holds colored byte ranges returned by the packet detail endpoint. +type Breakdown struct { + Ranges []HexRange `json:"ranges"` +} + +// BuildBreakdown computes labeled byte ranges for each section of a MeshCore packet. +// The returned ranges are consumed by createColoredHexDump() and buildHexLegend() +// in the frontend (public/app.js). +func BuildBreakdown(hexString string) *Breakdown { + hexString = strings.ReplaceAll(hexString, " ", "") + hexString = strings.ReplaceAll(hexString, "\n", "") + hexString = strings.ReplaceAll(hexString, "\r", "") + buf, err := hex.DecodeString(hexString) + if err != nil || len(buf) < 2 { + return &Breakdown{Ranges: []HexRange{}} + } + + var ranges []HexRange + offset := 0 + + // Byte 0: Header + ranges = append(ranges, HexRange{Start: 0, End: 0, Label: "Header"}) + offset = 1 + + header := decodeHeader(buf[0]) + + // Bytes 1-4: Transport Codes (TRANSPORT_FLOOD / TRANSPORT_DIRECT only) + if isTransportRoute(header.RouteType) { + if len(buf) < offset+4 { + return &Breakdown{Ranges: ranges} + } + ranges = append(ranges, HexRange{Start: offset, End: offset + 3, Label: "Transport Codes"}) + offset += 4 + } + + if offset >= len(buf) { + return &Breakdown{Ranges: ranges} + } + + // Next byte: Path Length (bits 7-6 = hashSize-1, bits 5-0 = hashCount) + ranges = append(ranges, HexRange{Start: offset, End: offset, Label: "Path Length"}) + pathByte := buf[offset] + offset++ + + hashSize := int(pathByte>>6) + 1 + hashCount := int(pathByte & 0x3F) + pathBytes := hashSize * hashCount + + // Path hops + if hashCount > 0 && offset+pathBytes <= len(buf) { + ranges = append(ranges, HexRange{Start: offset, End: offset + pathBytes - 1, Label: "Path"}) + } + offset += pathBytes + + if offset >= len(buf) { + return &Breakdown{Ranges: ranges} + } + + payloadStart := offset + + // Payload — break ADVERT into named sub-fields; everything else is one Payload range + if header.PayloadType == PayloadADVERT && len(buf)-payloadStart >= 100 { + ranges = append(ranges, HexRange{Start: payloadStart, End: payloadStart + 31, Label: "PubKey"}) + ranges = append(ranges, HexRange{Start: payloadStart + 32, End: payloadStart + 35, Label: "Timestamp"}) + ranges = append(ranges, HexRange{Start: payloadStart + 36, End: payloadStart + 99, Label: "Signature"}) + + appStart := payloadStart + 100 + if appStart < len(buf) { + ranges = append(ranges, HexRange{Start: appStart, End: appStart, Label: "Flags"}) + appFlags := buf[appStart] + fOff := appStart + 1 + if appFlags&0x10 != 0 && fOff+8 <= len(buf) { + ranges = append(ranges, HexRange{Start: fOff, End: fOff + 3, Label: "Latitude"}) + ranges = append(ranges, HexRange{Start: fOff + 4, End: fOff + 7, Label: "Longitude"}) + fOff += 8 + } + if appFlags&0x20 != 0 && fOff+2 <= len(buf) { + fOff += 2 + } + if appFlags&0x40 != 0 && fOff+2 <= len(buf) { + fOff += 2 + } + if appFlags&0x80 != 0 && fOff < len(buf) { + ranges = append(ranges, HexRange{Start: fOff, End: len(buf) - 1, Label: "Name"}) + } + } + } else { + ranges = append(ranges, HexRange{Start: payloadStart, End: len(buf) - 1, Label: "Payload"}) + } + + return &Breakdown{Ranges: ranges} +} + // ComputeContentHash computes the SHA-256-based content hash (first 16 hex chars). func ComputeContentHash(rawHex string) string { buf, err := hex.DecodeString(rawHex) diff --git a/cmd/server/decoder_test.go b/cmd/server/decoder_test.go index e2b7f6be..0a49855b 100644 --- a/cmd/server/decoder_test.go +++ b/cmd/server/decoder_test.go @@ -93,3 +93,152 @@ func TestDecodePacket_FloodHasNoCodes(t *testing.T) { t.Error("expected no transport codes for FLOOD route") } } + +func TestBuildBreakdown_InvalidHex(t *testing.T) { + b := BuildBreakdown("not-hex!") + if len(b.Ranges) != 0 { + t.Errorf("expected empty ranges for invalid hex, got %d", len(b.Ranges)) + } +} + +func TestBuildBreakdown_TooShort(t *testing.T) { + b := BuildBreakdown("11") // 1 byte — no path byte + if len(b.Ranges) != 0 { + t.Errorf("expected empty ranges for too-short packet, got %d", len(b.Ranges)) + } +} + +func TestBuildBreakdown_FloodNonAdvert(t *testing.T) { + // Header 0x15: route=1/FLOOD, payload=5/GRP_TXT + // PathByte 0x01: 1 hop, 1-byte hash + // PathHop: AA + // Payload: FF0011 + b := BuildBreakdown("1501AAFFFF00") + labels := rangeLabels(b.Ranges) + expect := []string{"Header", "Path Length", "Path", "Payload"} + if !equalLabels(labels, expect) { + t.Errorf("expected labels %v, got %v", expect, labels) + } + // Verify byte positions + assertRange(t, b.Ranges, "Header", 0, 0) + assertRange(t, b.Ranges, "Path Length", 1, 1) + assertRange(t, b.Ranges, "Path", 2, 2) + assertRange(t, b.Ranges, "Payload", 3, 5) +} + +func TestBuildBreakdown_TransportFlood(t *testing.T) { + // Header 0x14: route=0/TRANSPORT_FLOOD, payload=5/GRP_TXT + // TransportCodes: AABBCCDD (4 bytes) + // PathByte 0x01: 1 hop, 1-byte hash + // PathHop: EE + // Payload: FF00 + b := BuildBreakdown("14AABBCCDD01EEFF00") + assertRange(t, b.Ranges, "Header", 0, 0) + assertRange(t, b.Ranges, "Transport Codes", 1, 4) + assertRange(t, b.Ranges, "Path Length", 5, 5) + assertRange(t, b.Ranges, "Path", 6, 6) + assertRange(t, b.Ranges, "Payload", 7, 8) +} + +func TestBuildBreakdown_FloodNoHops(t *testing.T) { + // Header 0x15: FLOOD/GRP_TXT; PathByte 0x00: 0 hops; Payload: AABB + b := BuildBreakdown("150000AABB") + assertRange(t, b.Ranges, "Header", 0, 0) + assertRange(t, b.Ranges, "Path Length", 1, 1) + // No Path range since hashCount=0 + for _, r := range b.Ranges { + if r.Label == "Path" { + t.Error("expected no Path range for zero-hop packet") + } + } + assertRange(t, b.Ranges, "Payload", 2, 4) +} + +func TestBuildBreakdown_AdvertBasic(t *testing.T) { + // Header 0x11: FLOOD/ADVERT + // PathByte 0x01: 1 hop, 1-byte hash + // PathHop: AA + // Payload: 100 bytes (PubKey32 + Timestamp4 + Signature64) + Flags=0x02 (repeater, no extras) + pubkey := repeatHex("AB", 32) + ts := "00000000" // 4 bytes + sig := repeatHex("CD", 64) + flags := "02" + hex := "1101AA" + pubkey + ts + sig + flags + b := BuildBreakdown(hex) + assertRange(t, b.Ranges, "Header", 0, 0) + assertRange(t, b.Ranges, "Path Length", 1, 1) + assertRange(t, b.Ranges, "Path", 2, 2) + assertRange(t, b.Ranges, "PubKey", 3, 34) + assertRange(t, b.Ranges, "Timestamp", 35, 38) + assertRange(t, b.Ranges, "Signature", 39, 102) + assertRange(t, b.Ranges, "Flags", 103, 103) +} + +func TestBuildBreakdown_AdvertWithLocation(t *testing.T) { + // flags=0x12: hasLocation bit set + pubkey := repeatHex("00", 32) + ts := "00000000" + sig := repeatHex("00", 64) + flags := "12" // 0x10 = hasLocation + latBytes := "00000000" + lonBytes := "00000000" + hex := "1101AA" + pubkey + ts + sig + flags + latBytes + lonBytes + b := BuildBreakdown(hex) + assertRange(t, b.Ranges, "Latitude", 104, 107) + assertRange(t, b.Ranges, "Longitude", 108, 111) +} + +func TestBuildBreakdown_AdvertWithName(t *testing.T) { + // flags=0x82: hasName bit set + pubkey := repeatHex("00", 32) + ts := "00000000" + sig := repeatHex("00", 64) + flags := "82" // 0x80 = hasName + name := "4E6F6465" // "Node" in hex + hex := "1101AA" + pubkey + ts + sig + flags + name + b := BuildBreakdown(hex) + assertRange(t, b.Ranges, "Name", 104, 107) +} + +// helpers + +func rangeLabels(ranges []HexRange) []string { + out := make([]string, len(ranges)) + for i, r := range ranges { + out[i] = r.Label + } + return out +} + +func equalLabels(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func assertRange(t *testing.T, ranges []HexRange, label string, wantStart, wantEnd int) { + t.Helper() + for _, r := range ranges { + if r.Label == label { + if r.Start != wantStart || r.End != wantEnd { + t.Errorf("range %q: want [%d,%d], got [%d,%d]", label, wantStart, wantEnd, r.Start, r.End) + } + return + } + } + t.Errorf("range %q not found in %v", label, rangeLabels(ranges)) +} + +func repeatHex(byteHex string, n int) string { + s := "" + for i := 0; i < n; i++ { + s += byteHex + } + return s +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 87e5f71b..85ba5da6 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -761,10 +761,11 @@ func (s *Server) handlePacketDetail(w http.ResponseWriter, r *http.Request) { pathHops = []interface{}{} } + rawHex, _ := packet["raw_hex"].(string) writeJSON(w, PacketDetailResponse{ Packet: packet, Path: pathHops, - Breakdown: struct{}{}, + Breakdown: BuildBreakdown(rawHex), ObservationCount: observationCount, Observations: mapSliceToObservations(observations), }) diff --git a/cmd/server/types.go b/cmd/server/types.go index 559e1cc6..2da4ca80 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -289,7 +289,7 @@ type PacketTimestampsResponse struct { type PacketDetailResponse struct { Packet interface{} `json:"packet"` Path []interface{} `json:"path"` - Breakdown interface{} `json:"breakdown"` + Breakdown *Breakdown `json:"breakdown"` ObservationCount int `json:"observation_count"` Observations []ObservationResp `json:"observations,omitempty"` } diff --git a/public/packets.js b/public/packets.js index 18432a62..a3ce5324 100644 --- a/public/packets.js +++ b/public/packets.js @@ -1689,7 +1689,7 @@ let rows = ''; // Header section - rows += sectionRow('Header'); + rows += sectionRow('Header', 'section-header'); rows += fieldRow(0, 'Header Byte', '0x' + (buf.slice(0, 2) || '??'), `Route: ${routeTypeName(pkt.route_type)}, Payload: ${payloadTypeName(pkt.payload_type)}`); const pathByte0 = parseInt(buf.slice(2, 4), 16); const hashSizeVal = isNaN(pathByte0) ? '?' : ((pathByte0 >> 6) + 1); @@ -1699,7 +1699,7 @@ // Transport codes let off = 2; if (pkt.route_type === 0 || pkt.route_type === 3) { - rows += sectionRow('Transport Codes'); + rows += sectionRow('Transport Codes', 'section-transport'); rows += fieldRow(off, 'Next Hop', buf.slice(off * 2, (off + 2) * 2), ''); rows += fieldRow(off + 2, 'Last Hop', buf.slice((off + 2) * 2, (off + 4) * 2), ''); off += 4; @@ -1707,7 +1707,7 @@ // Path if (pathHops.length > 0) { - rows += sectionRow('Path (' + pathHops.length + ' hops)'); + rows += sectionRow('Path (' + pathHops.length + ' hops)', 'section-path'); const pathByte = parseInt(buf.slice(2, 4), 16); const hashSize = (pathByte >> 6) + 1; for (let i = 0; i < pathHops.length; i++) { @@ -1719,7 +1719,7 @@ } // Payload - rows += sectionRow('Payload — ' + payloadTypeName(pkt.payload_type)); + rows += sectionRow('Payload — ' + payloadTypeName(pkt.payload_type), 'section-payload'); if (decoded.type === 'ADVERT') { rows += fieldRow(1, 'Advertised Hash Size', hashSizeVal + ' byte' + (hashSizeVal !== 1 ? 's' : ''), 'From path byte 0x' + (buf.slice(2, 4) || '??') + ' — bits 7-6 = ' + (hashSizeVal - 1)); @@ -1769,8 +1769,8 @@ `; } - function sectionRow(label) { - return `