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 `
${renderNodeTimestampHtml(p.timestamp)} diff --git a/public/packets.js b/public/packets.js index 4bbe1359..7a611102 100644 --- a/public/packets.js +++ b/public/packets.js @@ -1755,7 +1755,7 @@ // Parse hash size from path byte const rawPathByte = pkt.raw_hex ? parseInt(pkt.raw_hex.slice(2, 4), 16) : NaN; - const hashSize = isNaN(rawPathByte) ? null : ((rawPathByte >> 6) + 1); + const hashSize = (isNaN(rawPathByte) || (rawPathByte & 0x3F) === 0) ? null : ((rawPathByte >> 6) + 1); const size = pkt.raw_hex ? Math.floor(pkt.raw_hex.length / 2) : 0; const typeName = payloadTypeName(pkt.payload_type); @@ -1977,7 +1977,7 @@ const pathByte0 = parseInt(buf.slice(2, 4), 16); const hashSizeVal = isNaN(pathByte0) ? '?' : ((pathByte0 >> 6) + 1); const hashCountVal = isNaN(pathByte0) ? '?' : (pathByte0 & 0x3F); - rows += fieldRow(1, 'Path Length', '0x' + (buf.slice(2, 4) || '??'), `hash_size=${hashSizeVal} byte${hashSizeVal !== 1 ? 's' : ''}, hash_count=${hashCountVal}`); + rows += fieldRow(1, 'Path Length', '0x' + (buf.slice(2, 4) || '??'), hashCountVal === 0 ? `hash_count=0 (direct advert)` : `hash_size=${hashSizeVal} byte${hashSizeVal !== 1 ? 's' : ''}, hash_count=${hashCountVal}`); // Transport codes let off = 2; @@ -2005,7 +2005,7 @@ 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)); + if (hashCountVal !== 0) rows += fieldRow(1, 'Advertised Hash Size', hashSizeVal + ' byte' + (hashSizeVal !== 1 ? 's' : ''), 'From path byte 0x' + (buf.slice(2, 4) || '??') + ' — bits 7-6 = ' + (hashSizeVal - 1)); rows += fieldRow(off, 'Public Key (32B)', truncate(decoded.pubKey || '', 24), ''); rows += fieldRow(off + 32, 'Timestamp (4B)', decoded.timestampISO || '', 'Unix: ' + (decoded.timestamp || '')); rows += fieldRow(off + 36, 'Signature (64B)', truncate(decoded.signature || '', 24), '');