diff --git a/cmd/server/routes_test.go b/cmd/server/routes_test.go index 02f896a3..a06201c8 100644 --- a/cmd/server/routes_test.go +++ b/cmd/server/routes_test.go @@ -2178,6 +2178,124 @@ func TestGetNodeHashSizeInfoLatestWins(t *testing.T) { } } +func TestGetNodeHashSizeInfoIgnoreDirectZeroHop(t *testing.T) { + db := setupTestDB(t) + seedTestData(t, db) + store := NewPacketStore(db, nil) + if err := store.Load(); err != nil { + t.Fatalf("store.Load failed: %v", err) + } + + pk := "dddd111122223333444455556666777788889999aaaabbbbccccddddeeee3333" + db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'DirIgnore', 'repeater')", pk) + + decoded := `{"name":"DirIgnore","pubKey":"` + pk + `"}` + rawFlood2B := "11" + "40" + "aabb" // FLOOD advert, hashSize=2 + rawDirect0 := "12" + "00" + "aabb" // DIRECT advert, zero-hop (should be ignored) + + payloadType := 4 + raws := []string{rawFlood2B, rawDirect0, rawFlood2B, rawDirect0, rawFlood2B} + for i, raw := range raws { + tx := &StoreTx{ + ID: 9150 + i, + RawHex: raw, + Hash: "dirignore" + strconv.Itoa(i), + FirstSeen: "2024-01-01T0" + strconv.Itoa(i) + ":00:00Z", + PayloadType: &payloadType, + DecodedJSON: decoded, + } + store.packets = append(store.packets, tx) + store.byPayloadType[4] = append(store.byPayloadType[4], tx) + } + + info := store.GetNodeHashSizeInfo() + ni := info[pk] + if ni == nil { + t.Fatal("expected hash info for test node") + } + if ni.HashSize != 2 { + t.Errorf("HashSize=%d, want 2 (direct zero-hop adverts should be ignored)", ni.HashSize) + } + if ni.Inconsistent { + t.Error("expected hash_size_inconsistent=false when direct zero-hop adverts are ignored") + } + if len(ni.AllSizes) != 1 || !ni.AllSizes[2] { + t.Errorf("expected only 2-byte size in AllSizes, got %#v", ni.AllSizes) + } +} + +func TestGetNodeHashSizeInfoOnlyDirectZeroHopIgnored(t *testing.T) { + db := setupTestDB(t) + seedTestData(t, db) + store := NewPacketStore(db, nil) + if err := store.Load(); err != nil { + t.Fatalf("store.Load failed: %v", err) + } + + pk := "eeee111122223333444455556666777788889999aaaabbbbccccddddeeee4444" + db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'OnlyDirect', 'repeater')", pk) + + decoded := `{"name":"OnlyDirect","pubKey":"` + pk + `"}` + rawDirect0 := "12" + "00" + "aabb" + payloadType := 4 + + tx := &StoreTx{ + ID: 9160, + RawHex: rawDirect0, + Hash: "onlydirect0", + FirstSeen: "2024-01-01T00:00:00Z", + PayloadType: &payloadType, + DecodedJSON: decoded, + } + store.packets = append(store.packets, tx) + store.byPayloadType[4] = append(store.byPayloadType[4], tx) + + info := store.GetNodeHashSizeInfo() + if ni := info[pk]; ni != nil { + t.Errorf("expected nil hash info for direct zero-hop only node, got HashSize=%d", ni.HashSize) + } +} + +func TestGetNodeHashSizeInfoDirectNonZeroHopCounted(t *testing.T) { + // A DIRECT advert with non-zero hop count should NOT be skipped — + // only zero-hop DIRECT adverts misreport hash size. + db := setupTestDB(t) + seedTestData(t, db) + store := NewPacketStore(db, nil) + if err := store.Load(); err != nil { + t.Fatalf("store.Load failed: %v", err) + } + + pk := "ffff111122223333444455556666777788889999aaaabbbbccccddddeeee5555" + db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'DirNonZero', 'repeater')", pk) + + decoded := `{"name":"DirNonZero","pubKey":"` + pk + `"}` + // DIRECT advert (route type 2 = 0x02 in bits 0-1), path byte 0x41: + // upper 2 bits = 01 → hash_size = 2, lower 6 bits = 0x01 → hop count 1 (non-zero) + rawDirectNonZero := "12" + "41" + "aabb" // header=0x12 (ADVERT|DIRECT), path=0x41 + payloadType := 4 + + tx := &StoreTx{ + ID: 9170, + RawHex: rawDirectNonZero, + Hash: "dirnonzero0", + FirstSeen: "2024-01-01T00:00:00Z", + PayloadType: &payloadType, + DecodedJSON: decoded, + } + store.packets = append(store.packets, tx) + store.byPayloadType[4] = append(store.byPayloadType[4], tx) + + info := store.GetNodeHashSizeInfo() + ni := info[pk] + if ni == nil { + t.Fatal("expected hash info for DIRECT non-zero-hop node — it should NOT be skipped") + } + if ni.HashSize != 2 { + t.Errorf("HashSize=%d, want 2 (DIRECT with hop count > 0 should be counted)", ni.HashSize) + } +} + func TestGetNodeHashSizeInfoNoAdverts(t *testing.T) { // A node with no ADVERT packets should not appear in hash size info. db := setupTestDB(t) diff --git a/cmd/server/store.go b/cmd/server/store.go index 2083e473..480b1e54 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -4537,10 +4537,20 @@ func (s *PacketStore) computeNodeHashSizeInfo() map[string]*hashSizeNodeInfo { if len(tx.RawHex) < 4 { continue } + header, err := strconv.ParseUint(tx.RawHex[:2], 16, 8) + if err != nil { + continue + } + routeType := int(header & 0x03) pathByte, err := strconv.ParseUint(tx.RawHex[2:4], 16, 8) if err != nil { continue } + // DIRECT zero-hop adverts use path byte 0x00 locally and can misreport + // multibyte repeater hash mode as 1-byte. + if routeType == RouteDirect && (pathByte&0x3F) == 0 { + continue + } hs := int((pathByte>>6)&0x3) + 1 var d map[string]interface{}