diff --git a/cmd/ingestor/decoder.go b/cmd/ingestor/decoder.go index 99b80619..9d29746b 100644 --- a/cmd/ingestor/decoder.go +++ b/cmd/ingestor/decoder.go @@ -587,6 +587,16 @@ func DecodePacket(hexString string, channelKeys map[string]string) (*DecodedPack } } + // Zero-hop direct packets have hash_count=0 (lower 6 bits of pathByte), + // which makes the generic formula yield a bogus hashSize. Reset to 0 + // (unknown) so API consumers get correct data. We mask with 0x3F to check + // only hash_count, matching the JS frontend approach — the upper hash_size + // bits are meaningless when there are no hops. Skip TRACE packets — they + // use hashSize to parse hops from the payload above. + if (header.RouteType == RouteDirect || header.RouteType == RouteTransportDirect) && pathByte&0x3F == 0 && header.PayloadType != PayloadTRACE { + path.HashSize = 0 + } + return &DecodedPacket{ Header: header, TransportCodes: tc, diff --git a/cmd/ingestor/decoder_test.go b/cmd/ingestor/decoder_test.go index 3fe09cda..22697525 100644 --- a/cmd/ingestor/decoder_test.go +++ b/cmd/ingestor/decoder_test.go @@ -1542,3 +1542,63 @@ func TestDecodeAdvertTelemetryZeroTemp(t *testing.T) { t.Errorf("temperature_c=%f, want 0.0", *pkt.Payload.TemperatureC) } } + +func repeatHex(byteHex string, n int) string { + s := "" + for i := 0; i < n; i++ { + s += byteHex + } + return s +} + +func TestZeroHopDirectHashSize(t *testing.T) { + // DIRECT (RouteType=2) + REQ (PayloadType=0) → header byte = 0x02 + // pathByte=0x00 → hash_count=0, hash_size bits=0 → should get HashSize=0 + hex := "02" + "00" + repeatHex("AA", 20) + pkt, err := DecodePacket(hex, nil) + if err != nil { + t.Fatalf("DecodePacket failed: %v", err) + } + if pkt.Path.HashSize != 0 { + t.Errorf("DIRECT zero-hop: want HashSize=0, got %d", pkt.Path.HashSize) + } +} + +func TestZeroHopDirectHashSizeWithNonZeroUpperBits(t *testing.T) { + // DIRECT (RouteType=2) + REQ (PayloadType=0) → header byte = 0x02 + // pathByte=0x40 → hash_count=0, hash_size bits=01 → should still get HashSize=0 + hex := "02" + "40" + repeatHex("AA", 20) + pkt, err := DecodePacket(hex, nil) + if err != nil { + t.Fatalf("DecodePacket failed: %v", err) + } + if pkt.Path.HashSize != 0 { + t.Errorf("DIRECT zero-hop with hash_size bits set: want HashSize=0, got %d", pkt.Path.HashSize) + } +} + +func TestNonDirectZeroPathByteKeepsHashSize(t *testing.T) { + // FLOOD (RouteType=1) + REQ (PayloadType=0) → header byte = 0x01 + // pathByte=0x00 → non-DIRECT should keep HashSize=1 + hex := "01" + "00" + repeatHex("AA", 20) + pkt, err := DecodePacket(hex, nil) + if err != nil { + t.Fatalf("DecodePacket failed: %v", err) + } + if pkt.Path.HashSize != 1 { + t.Errorf("FLOOD zero pathByte: want HashSize=1, got %d", pkt.Path.HashSize) + } +} + +func TestDirectNonZeroHopKeepsHashSize(t *testing.T) { + // DIRECT (RouteType=2) + REQ (PayloadType=0) → header byte = 0x02 + // pathByte=0x01 → hash_count=1, hash_size=1 → should keep HashSize=1 + hex := "02" + "01" + repeatHex("BB", 21) + pkt, err := DecodePacket(hex, nil) + if err != nil { + t.Fatalf("DecodePacket failed: %v", err) + } + if pkt.Path.HashSize != 1 { + t.Errorf("DIRECT with 1 hop: want HashSize=1, got %d", pkt.Path.HashSize) + } +} diff --git a/cmd/server/decoder.go b/cmd/server/decoder.go index d9dc77f6..84cf2291 100644 --- a/cmd/server/decoder.go +++ b/cmd/server/decoder.go @@ -388,6 +388,16 @@ func DecodePacket(hexString string) (*DecodedPacket, error) { } } + // Zero-hop direct packets have hash_count=0 (lower 6 bits of pathByte), + // which makes the generic formula yield a bogus hashSize. Reset to 0 + // (unknown) so API consumers get correct data. We mask with 0x3F to check + // only hash_count, matching the JS frontend approach — the upper hash_size + // bits are meaningless when there are no hops. Skip TRACE packets — they + // use hashSize to parse hops from the payload above. + if (header.RouteType == RouteDirect || header.RouteType == RouteTransportDirect) && pathByte&0x3F == 0 && header.PayloadType != PayloadTRACE { + path.HashSize = 0 + } + return &DecodedPacket{ Header: header, TransportCodes: tc, diff --git a/cmd/server/decoder_test.go b/cmd/server/decoder_test.go index 0a49855b..fc409092 100644 --- a/cmd/server/decoder_test.go +++ b/cmd/server/decoder_test.go @@ -235,6 +235,87 @@ func assertRange(t *testing.T, ranges []HexRange, label string, wantStart, wantE t.Errorf("range %q not found in %v", label, rangeLabels(ranges)) } +func TestZeroHopDirectHashSize(t *testing.T) { + // DIRECT (RouteType=2) + REQ (PayloadType=0) → header byte = 0x02 + // pathByte=0x00 → hash_count=0, hash_size bits=0 → should get HashSize=0 + // Need at least a few payload bytes after pathByte. + hex := "02" + "00" + repeatHex("AA", 20) + pkt, err := DecodePacket(hex) + if err != nil { + t.Fatalf("DecodePacket failed: %v", err) + } + if pkt.Path.HashSize != 0 { + t.Errorf("DIRECT zero-hop: want HashSize=0, got %d", pkt.Path.HashSize) + } +} + +func TestZeroHopDirectHashSizeWithNonZeroUpperBits(t *testing.T) { + // DIRECT (RouteType=2) + REQ (PayloadType=0) → header byte = 0x02 + // pathByte=0x40 → hash_count=0, hash_size bits=01 → should still get HashSize=0 + // because hash_count is zero (lower 6 bits are 0). + hex := "02" + "40" + repeatHex("AA", 20) + pkt, err := DecodePacket(hex) + if err != nil { + t.Fatalf("DecodePacket failed: %v", err) + } + if pkt.Path.HashSize != 0 { + t.Errorf("DIRECT zero-hop with hash_size bits set: want HashSize=0, got %d", pkt.Path.HashSize) + } +} + +func TestZeroHopTransportDirectHashSize(t *testing.T) { + // TRANSPORT_DIRECT (RouteType=3) + REQ (PayloadType=0) → header byte = 0x03 + // 4 bytes transport codes + pathByte=0x00 → hash_count=0 → should get HashSize=0 + hex := "03" + "11223344" + "00" + repeatHex("AA", 20) + pkt, err := DecodePacket(hex) + if err != nil { + t.Fatalf("DecodePacket failed: %v", err) + } + if pkt.Path.HashSize != 0 { + t.Errorf("TRANSPORT_DIRECT zero-hop: want HashSize=0, got %d", pkt.Path.HashSize) + } +} + +func TestZeroHopTransportDirectHashSizeWithNonZeroUpperBits(t *testing.T) { + // TRANSPORT_DIRECT (RouteType=3) + REQ (PayloadType=0) → header byte = 0x03 + // 4 bytes transport codes + pathByte=0xC0 → hash_count=0, hash_size bits=11 → should still get HashSize=0 + hex := "03" + "11223344" + "C0" + repeatHex("AA", 20) + pkt, err := DecodePacket(hex) + if err != nil { + t.Fatalf("DecodePacket failed: %v", err) + } + if pkt.Path.HashSize != 0 { + t.Errorf("TRANSPORT_DIRECT zero-hop with hash_size bits set: want HashSize=0, got %d", pkt.Path.HashSize) + } +} + +func TestNonDirectZeroPathByteKeepsHashSize(t *testing.T) { + // FLOOD (RouteType=1) + REQ (PayloadType=0) → header byte = 0x01 + // pathByte=0x00 → even though hash_count=0, non-DIRECT should keep HashSize=1 + hex := "01" + "00" + repeatHex("AA", 20) + pkt, err := DecodePacket(hex) + if err != nil { + t.Fatalf("DecodePacket failed: %v", err) + } + if pkt.Path.HashSize != 1 { + t.Errorf("FLOOD zero pathByte: want HashSize=1 (unchanged), got %d", pkt.Path.HashSize) + } +} + +func TestDirectNonZeroHopKeepsHashSize(t *testing.T) { + // DIRECT (RouteType=2) + REQ (PayloadType=0) → header byte = 0x02 + // pathByte=0x01 → hash_count=1, hash_size=1 → should keep HashSize=1 + // Need 1 hop hash byte after pathByte. + hex := "02" + "01" + repeatHex("BB", 21) + pkt, err := DecodePacket(hex) + if err != nil { + t.Fatalf("DecodePacket failed: %v", err) + } + if pkt.Path.HashSize != 1 { + t.Errorf("DIRECT with 1 hop: want HashSize=1, got %d", pkt.Path.HashSize) + } +} + func repeatHex(byteHex string, n int) string { s := "" for i := 0; i < n; i++ { diff --git a/public/nodes.js b/public/nodes.js index 2f02fcdd..0cfcd78e 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -548,10 +548,12 @@ let hashSizeBadge = ''; if (n.hash_size_inconsistent && p.payload_type === 4 && p.raw_hex) { const pb = parseInt(p.raw_hex.slice(2, 4), 16); - const hs = ((pb >> 6) & 0x3) + 1; - const hsColor = hs >= 3 ? '#16a34a' : hs === 2 ? '#86efac' : '#f97316'; - const hsFg = hs === 2 ? '#064e3b' : '#fff'; - hashSizeBadge = ` ${hs}B`; + if ((pb & 0x3F) !== 0) { + const hs = ((pb >> 6) & 0x3) + 1; + const hsColor = hs >= 3 ? '#16a34a' : hs === 2 ? '#86efac' : '#f97316'; + const hsFg = hs === 2 ? '#064e3b' : '#fff'; + hashSizeBadge = ` ${hs}B`; + } } return `