mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-04-08 19:55:47 +00:00
Compare commits
5 Commits
docs/relea
...
fix/hex-br
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31745f9edc | ||
|
|
a89b577ce5 | ||
|
|
6cd616bcef | ||
|
|
1d1cd46d3b | ||
|
|
bc92b8b5c9 |
@@ -163,6 +163,62 @@ func isTransportRoute(routeType int) bool {
|
||||
return routeType == RouteTransportFlood || routeType == RouteTransportDirect
|
||||
}
|
||||
|
||||
// cleanHex removes whitespace from a hex string.
|
||||
func cleanHex(s string) string {
|
||||
s = strings.ReplaceAll(s, " ", "")
|
||||
s = strings.ReplaceAll(s, "\n", "")
|
||||
s = strings.ReplaceAll(s, "\r", "")
|
||||
return s
|
||||
}
|
||||
|
||||
// packetFrame holds parsed packet frame offsets used by both DecodePacket and BuildBreakdown.
|
||||
type packetFrame struct {
|
||||
buf []byte
|
||||
header Header
|
||||
hasTransport bool
|
||||
transportOffset int // start of transport codes (if present)
|
||||
pathOffset int // offset of path length byte
|
||||
pathByte byte
|
||||
hashSize int
|
||||
hashCount int
|
||||
pathDataOffset int // start of path hop data
|
||||
payloadOffset int // start of payload
|
||||
}
|
||||
|
||||
// parsePacketFrame parses the common packet frame structure (header, transport codes, path).
|
||||
// Returns nil if the packet is too short.
|
||||
func parsePacketFrame(buf []byte) *packetFrame {
|
||||
if len(buf) < 2 {
|
||||
return nil
|
||||
}
|
||||
f := &packetFrame{buf: buf}
|
||||
f.header = decodeHeader(buf[0])
|
||||
offset := 1
|
||||
|
||||
f.hasTransport = isTransportRoute(f.header.RouteType)
|
||||
if f.hasTransport {
|
||||
if len(buf) < offset+4 {
|
||||
return nil
|
||||
}
|
||||
f.transportOffset = offset
|
||||
offset += 4
|
||||
}
|
||||
|
||||
if offset >= len(buf) {
|
||||
return nil
|
||||
}
|
||||
f.pathOffset = offset
|
||||
f.pathByte = buf[offset]
|
||||
offset++
|
||||
|
||||
f.hashSize = int(f.pathByte>>6) + 1
|
||||
f.hashCount = int(f.pathByte & 0x3F)
|
||||
f.pathDataOffset = offset
|
||||
offset += f.hashSize * f.hashCount
|
||||
f.payloadOffset = offset
|
||||
return f
|
||||
}
|
||||
|
||||
func decodeEncryptedPayload(typeName string, buf []byte) Payload {
|
||||
if len(buf) < 4 {
|
||||
return Payload{Type: typeName, Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
@@ -334,49 +390,34 @@ func decodePayload(payloadType int, buf []byte) Payload {
|
||||
|
||||
// DecodePacket decodes a hex-encoded MeshCore packet.
|
||||
func DecodePacket(hexString string) (*DecodedPacket, error) {
|
||||
hexString = strings.ReplaceAll(hexString, " ", "")
|
||||
hexString = strings.ReplaceAll(hexString, "\n", "")
|
||||
hexString = strings.ReplaceAll(hexString, "\r", "")
|
||||
hexString = cleanHex(hexString)
|
||||
|
||||
buf, err := hex.DecodeString(hexString)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid hex: %w", err)
|
||||
}
|
||||
if len(buf) < 2 {
|
||||
return nil, fmt.Errorf("packet too short (need at least header + pathLength)")
|
||||
}
|
||||
|
||||
header := decodeHeader(buf[0])
|
||||
offset := 1
|
||||
f := parsePacketFrame(buf)
|
||||
if f == nil {
|
||||
return nil, fmt.Errorf("packet too short")
|
||||
}
|
||||
|
||||
var tc *TransportCodes
|
||||
if isTransportRoute(header.RouteType) {
|
||||
if len(buf) < offset+4 {
|
||||
return nil, fmt.Errorf("packet too short for transport codes")
|
||||
}
|
||||
if f.hasTransport {
|
||||
tc = &TransportCodes{
|
||||
Code1: strings.ToUpper(hex.EncodeToString(buf[offset : offset+2])),
|
||||
Code2: strings.ToUpper(hex.EncodeToString(buf[offset+2 : offset+4])),
|
||||
Code1: strings.ToUpper(hex.EncodeToString(buf[f.transportOffset : f.transportOffset+2])),
|
||||
Code2: strings.ToUpper(hex.EncodeToString(buf[f.transportOffset+2 : f.transportOffset+4])),
|
||||
}
|
||||
offset += 4
|
||||
}
|
||||
|
||||
if offset >= len(buf) {
|
||||
return nil, fmt.Errorf("packet too short (no path byte)")
|
||||
}
|
||||
pathByte := buf[offset]
|
||||
offset++
|
||||
|
||||
path, bytesConsumed := decodePath(pathByte, buf, offset)
|
||||
offset += bytesConsumed
|
||||
|
||||
payloadBuf := buf[offset:]
|
||||
payload := decodePayload(header.PayloadType, payloadBuf)
|
||||
path, _ := decodePath(f.pathByte, buf, f.pathDataOffset)
|
||||
payloadBuf := buf[f.payloadOffset:]
|
||||
payload := decodePayload(f.header.PayloadType, payloadBuf)
|
||||
|
||||
// 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.
|
||||
if header.PayloadType == PayloadTRACE && payload.PathData != "" {
|
||||
if f.header.PayloadType == PayloadTRACE && payload.PathData != "" {
|
||||
pathBytes, err := hex.DecodeString(payload.PathData)
|
||||
if err == nil && path.HashSize > 0 {
|
||||
hops := make([]string, 0, len(pathBytes)/path.HashSize)
|
||||
@@ -389,7 +430,7 @@ func DecodePacket(hexString string) (*DecodedPacket, error) {
|
||||
}
|
||||
|
||||
return &DecodedPacket{
|
||||
Header: header,
|
||||
Header: f.header,
|
||||
TransportCodes: tc,
|
||||
Path: path,
|
||||
Payload: payload,
|
||||
@@ -397,6 +438,94 @@ 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/packets.js).
|
||||
func BuildBreakdown(hexString string) *Breakdown {
|
||||
hexString = cleanHex(hexString)
|
||||
buf, err := hex.DecodeString(hexString)
|
||||
if err != nil || len(buf) < 2 {
|
||||
return &Breakdown{Ranges: []HexRange{}}
|
||||
}
|
||||
|
||||
f := parsePacketFrame(buf)
|
||||
if f == nil {
|
||||
return &Breakdown{Ranges: []HexRange{{Start: 0, End: 0, Label: "Header"}}}
|
||||
}
|
||||
|
||||
var ranges []HexRange
|
||||
|
||||
// Header byte
|
||||
ranges = append(ranges, HexRange{Start: 0, End: 0, Label: "Header"})
|
||||
|
||||
// Transport codes
|
||||
if f.hasTransport {
|
||||
ranges = append(ranges, HexRange{Start: f.transportOffset, End: f.transportOffset + 3, Label: "Transport Codes"})
|
||||
}
|
||||
|
||||
// Path length byte
|
||||
ranges = append(ranges, HexRange{Start: f.pathOffset, End: f.pathOffset, Label: "Path Length"})
|
||||
|
||||
// Path hops
|
||||
pathBytes := f.hashSize * f.hashCount
|
||||
if f.hashCount > 0 && f.pathDataOffset+pathBytes <= len(buf) {
|
||||
ranges = append(ranges, HexRange{Start: f.pathDataOffset, End: f.pathDataOffset + pathBytes - 1, Label: "Path"})
|
||||
}
|
||||
|
||||
if f.payloadOffset >= len(buf) {
|
||||
return &Breakdown{Ranges: ranges}
|
||||
}
|
||||
|
||||
// Payload — break ADVERT into named sub-fields; everything else is one Payload range
|
||||
if f.header.PayloadType == PayloadADVERT && len(buf)-f.payloadOffset >= 100 {
|
||||
ps := f.payloadOffset
|
||||
ranges = append(ranges, HexRange{Start: ps, End: ps + 31, Label: "PubKey"})
|
||||
ranges = append(ranges, HexRange{Start: ps + 32, End: ps + 35, Label: "Timestamp"})
|
||||
ranges = append(ranges, HexRange{Start: ps + 36, End: ps + 99, Label: "Signature"})
|
||||
|
||||
appStart := ps + 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) {
|
||||
ranges = append(ranges, HexRange{Start: fOff, End: fOff + 1, Label: "Feature1"})
|
||||
fOff += 2
|
||||
}
|
||||
if appFlags&0x40 != 0 && fOff+2 <= len(buf) {
|
||||
ranges = append(ranges, HexRange{Start: fOff, End: fOff + 1, Label: "Feature2"})
|
||||
fOff += 2
|
||||
}
|
||||
if appFlags&0x80 != 0 && fOff < len(buf) {
|
||||
ranges = append(ranges, HexRange{Start: fOff, End: len(buf) - 1, Label: "Name"})
|
||||
} else if fOff < len(buf) {
|
||||
ranges = append(ranges, HexRange{Start: fOff, End: len(buf) - 1, Label: "AppData"})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ranges = append(ranges, HexRange{Start: f.payloadOffset, 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)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -93,3 +94,323 @@ 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 := strings.Repeat("AB", 32)
|
||||
ts := "00000000" // 4 bytes
|
||||
sig := strings.Repeat("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 := strings.Repeat("00", 32)
|
||||
ts := "00000000"
|
||||
sig := strings.Repeat("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 := strings.Repeat("00", 32)
|
||||
ts := "00000000"
|
||||
sig := strings.Repeat("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))
|
||||
}
|
||||
|
||||
|
||||
|
||||
// --- BuildBreakdown tests (PR #500 review feedback) ---
|
||||
|
||||
func TestBuildBreakdown_SimplePayload(t *testing.T) {
|
||||
// Header 0x11 = ADVERT + ZERO_HOP, path byte 0x00 = no hops
|
||||
// Payload < 100 bytes → single "Payload" range
|
||||
h := "1100" + strings.Repeat("AB", 10)
|
||||
bd := BuildBreakdown(h)
|
||||
labels := rangeLabels(bd.Ranges)
|
||||
expect := []string{"Header", "Path Length", "Payload"}
|
||||
if len(labels) != len(expect) {
|
||||
t.Fatalf("expected %v, got %v", expect, labels)
|
||||
}
|
||||
for i, e := range expect {
|
||||
if labels[i] != e {
|
||||
t.Errorf("range[%d]: expected %s, got %s", i, e, labels[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBreakdown_TransportDirect(t *testing.T) {
|
||||
// TXT_MSG (0x01) + TRANSPORT_DIRECT (route 3) = 0x07
|
||||
h := "07" + "AABBCCDD" + "00" + strings.Repeat("EE", 5)
|
||||
bd := BuildBreakdown(h)
|
||||
labels := rangeLabels(bd.Ranges)
|
||||
if len(labels) < 4 {
|
||||
t.Fatalf("expected ≥4 ranges, got %v", labels)
|
||||
}
|
||||
if labels[1] != "Transport Codes" {
|
||||
t.Errorf("expected Transport Codes, got %s", labels[1])
|
||||
}
|
||||
if bd.Ranges[1].Start != 1 || bd.Ranges[1].End != 4 {
|
||||
t.Errorf("transport range wrong: %d-%d", bd.Ranges[1].Start, bd.Ranges[1].End)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBreakdown_AdvertAllFlags(t *testing.T) {
|
||||
// ADVERT + ZERO_HOP = 0x11, path 0x00
|
||||
// flags 0xF2 = location(0x10) + feat1(0x20) + feat2(0x40) + name(0x80) + type 2
|
||||
pubkey := strings.Repeat("AA", 32)
|
||||
ts := "01020304"
|
||||
sig := strings.Repeat("BB", 64)
|
||||
flags := "F2"
|
||||
loc := "0100000002000000"
|
||||
feat1 := "C1C2"
|
||||
feat2 := "D1D2"
|
||||
name := strings.Repeat("48", 5)
|
||||
|
||||
h := "11" + "00" + pubkey + ts + sig + flags + loc + feat1 + feat2 + name
|
||||
bd := BuildBreakdown(h)
|
||||
labels := rangeLabels(bd.Ranges)
|
||||
|
||||
expect := []string{"Header", "Path Length", "PubKey", "Timestamp", "Signature",
|
||||
"Flags", "Latitude", "Longitude", "Feature1", "Feature2", "Name"}
|
||||
if len(labels) != len(expect) {
|
||||
t.Fatalf("expected %v, got %v", expect, labels)
|
||||
}
|
||||
for i, e := range expect {
|
||||
if labels[i] != e {
|
||||
t.Errorf("range[%d]: expected %s, got %s", i, e, labels[i])
|
||||
}
|
||||
}
|
||||
// Verify no overlaps
|
||||
for i := 1; i < len(bd.Ranges); i++ {
|
||||
if bd.Ranges[i].Start <= bd.Ranges[i-1].End {
|
||||
t.Errorf("overlap: %s [%d-%d] and %s [%d-%d]",
|
||||
bd.Ranges[i-1].Label, bd.Ranges[i-1].Start, bd.Ranges[i-1].End,
|
||||
bd.Ranges[i].Label, bd.Ranges[i].Start, bd.Ranges[i].End)
|
||||
}
|
||||
}
|
||||
// Feature1 & Feature2 are each 2 bytes
|
||||
if sz := bd.Ranges[8].End - bd.Ranges[8].Start + 1; sz != 2 {
|
||||
t.Errorf("Feature1 should be 2 bytes, got %d", sz)
|
||||
}
|
||||
if sz := bd.Ranges[9].End - bd.Ranges[9].Start + 1; sz != 2 {
|
||||
t.Errorf("Feature2 should be 2 bytes, got %d", sz)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBreakdown_AdvertFeat1Only(t *testing.T) {
|
||||
// flags 0xA1 = feat1(0x20) + name(0x80) + type 1, no location
|
||||
pubkey := strings.Repeat("AA", 32)
|
||||
ts := "01020304"
|
||||
sig := strings.Repeat("BB", 64)
|
||||
h := "11" + "00" + pubkey + ts + sig + "A1" + "F1F2" + strings.Repeat("4E", 4)
|
||||
bd := BuildBreakdown(h)
|
||||
labels := rangeLabels(bd.Ranges)
|
||||
|
||||
expect := []string{"Header", "Path Length", "PubKey", "Timestamp", "Signature",
|
||||
"Flags", "Feature1", "Name"}
|
||||
if len(labels) != len(expect) {
|
||||
t.Fatalf("expected %v, got %v", expect, labels)
|
||||
}
|
||||
for i, e := range expect {
|
||||
if labels[i] != e {
|
||||
t.Errorf("range[%d]: expected %s, got %s", i, e, labels[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePacket_TransportDirect(t *testing.T) {
|
||||
h := "07" + "AABBCCDD" + "00" + strings.Repeat("EE", 5)
|
||||
pkt, err := DecodePacket(h)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if pkt.Header.RouteType != RouteTransportDirect {
|
||||
t.Errorf("expected route %d, got %d", RouteTransportDirect, pkt.Header.RouteType)
|
||||
}
|
||||
if pkt.TransportCodes == nil {
|
||||
t.Fatal("expected transport codes")
|
||||
}
|
||||
if pkt.TransportCodes.Code1 != "AABB" {
|
||||
t.Errorf("Code1: expected AABB, got %s", pkt.TransportCodes.Code1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBreakdown_AdvertShortPayload(t *testing.T) {
|
||||
// ADVERT with payload < 100 bytes should get generic "Payload" label, not sub-ranges
|
||||
h := "11" + "00" + strings.Repeat("FF", 50) // header + path + 50 bytes payload (< 100)
|
||||
bd := BuildBreakdown(h)
|
||||
labels := rangeLabels(bd.Ranges)
|
||||
|
||||
// Should have Header, Path Length, Payload — no PubKey/Timestamp/Signature sub-ranges
|
||||
if labels[len(labels)-1] != "Payload" {
|
||||
t.Errorf("expected last range to be 'Payload', got %q", labels[len(labels)-1])
|
||||
}
|
||||
for _, l := range labels {
|
||||
if l == "PubKey" || l == "Timestamp" || l == "Signature" || l == "Flags" {
|
||||
t.Errorf("unexpected sub-range %q in short ADVERT", l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBreakdown_AdvertLocationAndName(t *testing.T) {
|
||||
// flags 0x91 = location(0x10) + name(0x80) + type 1
|
||||
pubkey := strings.Repeat("AA", 32)
|
||||
ts := "01020304"
|
||||
sig := strings.Repeat("BB", 64)
|
||||
lat := "11223344"
|
||||
lon := "55667788"
|
||||
name := strings.Repeat("4E", 6) // "NNNNNN"
|
||||
h := "11" + "00" + pubkey + ts + sig + "91" + lat + lon + name
|
||||
bd := BuildBreakdown(h)
|
||||
labels := rangeLabels(bd.Ranges)
|
||||
|
||||
expect := []string{"Header", "Path Length", "PubKey", "Timestamp", "Signature",
|
||||
"Flags", "Latitude", "Longitude", "Name"}
|
||||
if len(labels) != len(expect) {
|
||||
t.Fatalf("expected %v, got %v", expect, labels)
|
||||
}
|
||||
for i, e := range expect {
|
||||
if labels[i] != e {
|
||||
t.Errorf("range[%d]: expected %s, got %s", i, e, labels[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBreakdown_AdvertTrailingBytesNoName(t *testing.T) {
|
||||
// flags 0x11 = location(0x10) + type 1, NO name bit — trailing bytes should be "AppData"
|
||||
pubkey := strings.Repeat("AA", 32)
|
||||
ts := "01020304"
|
||||
sig := strings.Repeat("BB", 64)
|
||||
lat := "11223344"
|
||||
lon := "55667788"
|
||||
trailing := "DEADBEEF"
|
||||
h := "11" + "00" + pubkey + ts + sig + "11" + lat + lon + trailing
|
||||
bd := BuildBreakdown(h)
|
||||
labels := rangeLabels(bd.Ranges)
|
||||
|
||||
lastLabel := labels[len(labels)-1]
|
||||
if lastLabel != "AppData" {
|
||||
t.Errorf("expected trailing bytes labeled 'AppData', got %q", lastLabel)
|
||||
}
|
||||
}
|
||||
|
||||
// rangeLabels is defined earlier in this file
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -100,6 +100,11 @@
|
||||
selectedBg: '--selected-bg',
|
||||
font: '--font',
|
||||
mono: '--mono',
|
||||
// Hex breakdown section colors
|
||||
sectionHeaderBg: '--section-header-bg',
|
||||
sectionTransportBg: '--section-transport-bg',
|
||||
sectionPathBg: '--section-path-bg',
|
||||
sectionPayloadBg: '--section-payload-bg',
|
||||
};
|
||||
|
||||
/* ── Theme Presets ── */
|
||||
|
||||
@@ -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 @@
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function sectionRow(label) {
|
||||
return `<tr class="section-row"><td colspan="4">${label}</td></tr>`;
|
||||
function sectionRow(label, cls) {
|
||||
return `<tr class="section-row${cls ? ' ' + cls : ''}"><td colspan="4">${label}</td></tr>`;
|
||||
}
|
||||
function fieldRow(offset, name, value, desc) {
|
||||
return `<tr><td class="mono">${offset}</td><td>${name}</td><td class="mono">${value}</td><td class="text-muted">${desc || ''}</td></tr>`;
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
--content-bg: var(--surface-0);
|
||||
--card-bg: var(--surface-1);
|
||||
--hover-bg: rgba(0,0,0, 0.04);
|
||||
--section-header-bg: rgba(243,139,168,0.18);
|
||||
--section-transport-bg: rgba(137,180,250,0.18);
|
||||
--section-path-bg: rgba(166,227,161,0.18);
|
||||
--section-payload-bg: rgba(249,226,175,0.18);
|
||||
}
|
||||
|
||||
/* ⚠️ DARK THEME VARIABLES — KEEP BOTH BLOCKS IN SYNC
|
||||
@@ -56,6 +60,10 @@
|
||||
--selected-bg: #1e3a5f;
|
||||
--hover-bg: rgba(255,255,255, 0.06);
|
||||
--section-bg: #1e1e34;
|
||||
--section-header-bg: rgba(243,139,168,0.15);
|
||||
--section-transport-bg: rgba(137,180,250,0.15);
|
||||
--section-path-bg: rgba(166,227,161,0.15);
|
||||
--section-payload-bg: rgba(249,226,175,0.15);
|
||||
}
|
||||
}
|
||||
/* ⚠️ DARK THEME VARIABLES — KEEP IN SYNC with @media block above */
|
||||
@@ -79,6 +87,10 @@
|
||||
--selected-bg: #1e3a5f;
|
||||
--hover-bg: rgba(255,255,255, 0.06);
|
||||
--section-bg: #1e1e34;
|
||||
--section-header-bg: rgba(243,139,168,0.15);
|
||||
--section-transport-bg: rgba(137,180,250,0.15);
|
||||
--section-path-bg: rgba(166,227,161,0.15);
|
||||
--section-payload-bg: rgba(249,226,175,0.15);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
@@ -375,6 +387,10 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
background: var(--section-bg, #eef2ff); font-weight: 700; font-size: 11px;
|
||||
text-transform: uppercase; letter-spacing: .5px; color: var(--accent);
|
||||
}
|
||||
.field-table .section-header td { background: var(--section-header-bg); }
|
||||
.field-table .section-transport td { background: var(--section-transport-bg); }
|
||||
.field-table .section-path td { background: var(--section-path-bg); }
|
||||
.field-table .section-payload td { background: var(--section-payload-bg); }
|
||||
|
||||
/* === Path display === */
|
||||
.path-hops {
|
||||
|
||||
@@ -543,6 +543,51 @@ async function run() {
|
||||
assert(hasChannelHash, 'Undecrypted GRP_TXT detail should show "Channel Hash"');
|
||||
});
|
||||
|
||||
// --- Group: Hex breakdown colors (#329) ---
|
||||
|
||||
await test('Packet detail hex dump shows color-coded sections', async () => {
|
||||
// Find any packet with raw_hex via API
|
||||
const hash = await page.evaluate(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/packets?limit=100');
|
||||
const data = await res.json();
|
||||
for (const p of (data.packets || [])) {
|
||||
if (p.raw_hex && p.raw_hex.length > 10) return p.hash;
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
});
|
||||
if (!hash) { console.log(' ⏭️ Skipped (no packets with raw_hex found)'); return; }
|
||||
await page.goto(`${BASE}/#/packets/${hash}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => {
|
||||
const panel = document.getElementById('pktRight');
|
||||
if (!panel || panel.classList.contains('empty')) return false;
|
||||
return panel.textContent.length > 50 && !panel.textContent.includes('Loading');
|
||||
}, { timeout: 8000 });
|
||||
// Verify hex dump has colored spans (not monochrome)
|
||||
const coloredSpans = await page.$$eval('.hex-dump span[class*="hex-"]', els => els.length);
|
||||
assert(coloredSpans > 0, 'Hex dump should have color-coded spans with hex-* classes');
|
||||
});
|
||||
|
||||
await test('Packet detail shows hex legend with color swatches', async () => {
|
||||
// Re-use the packet detail page from previous test
|
||||
const legendItems = await page.$$eval('.hex-legend .legend-item, .hex-legend span', els => els.length);
|
||||
assert(legendItems > 0, 'Hex legend should show color swatch items');
|
||||
});
|
||||
|
||||
await test('Field breakdown table has tinted section rows', async () => {
|
||||
// Check that section-row elements have per-section color classes
|
||||
const sectionClasses = await page.$$eval('.field-table .section-row', rows =>
|
||||
rows.map(r => r.className)
|
||||
);
|
||||
assert(sectionClasses.length > 0, 'Field table should have section rows');
|
||||
const hasTinted = sectionClasses.some(c =>
|
||||
c.includes('section-header') || c.includes('section-transport') ||
|
||||
c.includes('section-path') || c.includes('section-payload')
|
||||
);
|
||||
assert(hasTinted, 'Section rows should have tinted color classes (section-header, section-path, etc.)');
|
||||
});
|
||||
|
||||
// --- Group: Analytics page (test 8 + sub-tabs) ---
|
||||
|
||||
// Test 8: Analytics page loads with overview
|
||||
|
||||
Reference in New Issue
Block a user