diff --git a/cmd/server/coverage_test.go b/cmd/server/coverage_test.go index cf9e7ac0..3b8f15e3 100644 --- a/cmd/server/coverage_test.go +++ b/cmd/server/coverage_test.go @@ -3217,6 +3217,189 @@ func TestGetNodeHashSizeInfoEdgeCases(t *testing.T) { } } +// TestHashSizeTransportRoutePathByteOffset verifies that transport routes (0, 3) +// read the path byte from offset 5 (after 4 transport code bytes), not offset 1. +// Regression test for #744 / #722. +func TestHashSizeTransportRoutePathByteOffset(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + now := time.Now().UTC() + recent := now.Add(-1 * time.Hour).Format(time.RFC3339) + recentEpoch := now.Add(-1 * time.Hour).Unix() + + db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count) + VALUES ('obs1', 'Obs', 'SJC', ?, '2026-01-01T00:00:00Z', 10)`, recent) + + // Route type 0 (TRANSPORT_FLOOD): header=0x04 (payload_type=1, route_type=0) + // 4 transport bytes + path byte at offset 5. + // Path byte 0x80 → hash_size bits = 10 → size 3 + // If bug is present, code reads byte 1 (0xAA) → hash_size bits = 10 → size 3 (coincidence) + // Use path byte 0x40 (hash_size=2) and transport byte 0x01 at offset 1 (hash_size=1 if misread) + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('100102030440aabb', 'tf_offset', ?, 0, 4, '{"pubKey":"aaaa000000000001","name":"TF-Node","type":"ADVERT"}')`, recent) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (1, 1, 10.0, -90, '[]', ?)`, recentEpoch) + + // Route type 3 (TRANSPORT_DIRECT): header=0x13 (payload_type=4, route_type=3) + // 4 transport bytes + path byte at offset 5. + // Path byte 0xC1 → hash_size bits = 11 → size 4, hop_count = 1 (not zero-hop) + // Byte 1 = 0x05 → hash_size bits = 00 → size 1 if misread + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('1305060708C1bbcc', 'td_offset', ?, 3, 4, '{"pubKey":"aaaa000000000002","name":"TD-Node","type":"ADVERT"}')`, recent) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (2, 1, 10.0, -90, '[]', ?)`, recentEpoch) + + // Route type 1 (FLOOD): header=0x11 (payload_type=4, route_type=1) + // Path byte at offset 1. Path byte 0x80 → hash_size bits = 10 → size 3 + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('1180aabbccdd', 'flood_offset', ?, 1, 4, '{"pubKey":"aaaa000000000003","name":"Flood-Node","type":"ADVERT"}')`, recent) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (3, 1, 10.0, -90, '[]', ?)`, recentEpoch) + + store := NewPacketStore(db, nil) + store.Load() + info := store.GetNodeHashSizeInfo() + + // Transport flood node: path byte 0x40 → hash_size = 2 + if ni, ok := info["aaaa000000000001"]; !ok { + t.Error("transport flood node missing from hash size info") + } else if ni.HashSize != 2 { + t.Errorf("transport flood node: want HashSize=2 (from path byte at offset 5), got %d", ni.HashSize) + } + + // Transport direct node: path byte 0xC1 → hash_size = 4 + if ni, ok := info["aaaa000000000002"]; !ok { + t.Error("transport direct node missing from hash size info") + } else if ni.HashSize != 4 { + t.Errorf("transport direct node: want HashSize=4 (from path byte at offset 5), got %d", ni.HashSize) + } + + // Regular flood node: path byte 0x80 → hash_size = 3 + if ni, ok := info["aaaa000000000003"]; !ok { + t.Error("regular flood node missing from hash size info") + } else if ni.HashSize != 3 { + t.Errorf("regular flood node: want HashSize=3 (from path byte at offset 1), got %d", ni.HashSize) + } +} + +// TestHashSizeTransportDirectZeroHopSkipped verifies that RouteTransportDirect +// zero-hop adverts are skipped (same as RouteDirect). Regression test for #744. +func TestHashSizeTransportDirectZeroHopSkipped(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + now := time.Now().UTC() + recent := now.Add(-1 * time.Hour).Format(time.RFC3339) + recentEpoch := now.Add(-1 * time.Hour).Unix() + + db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count) + VALUES ('obs1', 'Obs', 'SJC', ?, '2026-01-01T00:00:00Z', 10)`, recent) + + // RouteDirect (2) zero-hop: path byte 0x40 → hop_count=0, hash_size bits=01 + // Should be skipped (existing behavior) + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('1240aabbccdd', 'direct_zh', ?, 2, 4, '{"pubKey":"bbbb000000000001","name":"Direct-ZH","type":"ADVERT"}')`, recent) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (1, 1, 10.0, -90, '[]', ?)`, recentEpoch) + + // RouteTransportDirect (3) zero-hop: 4 transport bytes + path byte 0x40 → hop_count=0 + // Should ALSO be skipped (this was the missing case) + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('130102030440aabb', 'tdirect_zh', ?, 3, 4, '{"pubKey":"bbbb000000000002","name":"TDirect-ZH","type":"ADVERT"}')`, recent) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (2, 1, 10.0, -90, '[]', ?)`, recentEpoch) + + // RouteDirect (2) non-zero-hop: path byte 0x41 → hop_count=1 + // Should NOT be skipped + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('1241aabbccdd', 'direct_1h', ?, 2, 4, '{"pubKey":"bbbb000000000003","name":"Direct-1H","type":"ADVERT"}')`, recent) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (3, 1, 10.0, -90, '[]', ?)`, recentEpoch) + + store := NewPacketStore(db, nil) + store.Load() + info := store.GetNodeHashSizeInfo() + + // RouteDirect zero-hop should be absent + if _, ok := info["bbbb000000000001"]; ok { + t.Error("RouteDirect zero-hop advert should be skipped") + } + + // RouteTransportDirect zero-hop should also be absent + if _, ok := info["bbbb000000000002"]; ok { + t.Error("RouteTransportDirect zero-hop advert should be skipped") + } + + // RouteDirect non-zero-hop should be present with hash_size=2 + if ni, ok := info["bbbb000000000003"]; !ok { + t.Error("RouteDirect non-zero-hop should be in hash size info") + } else if ni.HashSize != 2 { + t.Errorf("RouteDirect non-zero-hop: want HashSize=2, got %d", ni.HashSize) + } +} + +// TestAnalyticsHashSizesZeroHopSkip verifies that computeAnalyticsHashSizes +// does not overwrite a node's hash_size with a zero-hop advert's unreliable value. +// Regression test for #744. +func TestAnalyticsHashSizesZeroHopSkip(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + now := time.Now().UTC() + recent := now.Add(-1 * time.Hour).Format(time.RFC3339) + recentEpoch := now.Add(-1 * time.Hour).Unix() + + db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count) + VALUES ('obs1', 'Obs', 'SJC', ?, '2026-01-01T00:00:00Z', 10)`, recent) + + pk := "cccc000000000001" + db.conn.Exec(`INSERT INTO nodes (public_key, name, role) VALUES (?, 'ZH-Analytics', 'repeater')`, pk) + + decoded := `{"pubKey":"` + pk + `","name":"ZH-Analytics","type":"ADVERT"}` + + // First: a flood advert with hashSize=2 (reliable, multi-hop) + // header 0x11 = route_type 1 (flood), payload_type 4 + // pathByte 0x41 = hashSize bits 01 → size 2, hop_count 1 + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('1141aabbccdd', 'az_flood', ?, 1, 4, ?)`, recent, decoded) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (1, 1, 10.0, -90, '["aabb"]', ?)`, recentEpoch) + + // Second: a direct zero-hop advert with pathByte=0x00 → would give hashSize=1 + // header 0x12 = route_type 2 (direct), payload_type 4 + db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json) + VALUES ('1200aabbccdd', 'az_direct', ?, 2, 4, ?)`, recent, decoded) + db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (2, 1, 10.0, -90, '[]', ?)`, recentEpoch) + + store := NewPacketStore(db, nil) + store.Load() + + result := store.GetAnalyticsHashSizes("") + + // The node should appear in multiByteNodes (hashSize=2 from the flood advert) + // If the zero-hop bug is present, hashSize would be 1 and the node would NOT + // appear in multiByteNodes. + multiByteNodes, ok := result["multiByteNodes"].([]map[string]interface{}) + if !ok { + t.Fatal("expected multiByteNodes slice in analytics hash sizes") + } + + found := false + for _, n := range multiByteNodes { + if n["pubkey"] == pk { + found = true + if hs, ok := n["hashSize"].(int); ok && hs != 2 { + t.Errorf("expected hashSize=2 from flood advert, got %d", hs) + } + } + } + if !found { + t.Error("node should appear in multiByteNodes with hashSize=2; zero-hop advert should not overwrite to 1") + } +} + func TestHandleResolveHopsEdgeCases(t *testing.T) { db := setupTestDB(t) defer db.Close() diff --git a/cmd/server/routes_test.go b/cmd/server/routes_test.go index 09d2108b..50a36630 100644 --- a/cmd/server/routes_test.go +++ b/cmd/server/routes_test.go @@ -2219,8 +2219,8 @@ pk := "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'TestNode', 'repeater')", pk) decoded := `{"name":"TestNode","pubKey":"` + pk + `"}` -raw1 := "04" + "00" + "aabb" -raw2 := "04" + "40" + "aabb" +raw1 := "11" + "01" + "aabb" +raw2 := "11" + "41" + "aabb" payloadType := 4 for i := 0; i < 3; i++ { @@ -2267,8 +2267,8 @@ pk := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'Repeater2B', 'repeater')", pk) decoded := `{"name":"Repeater2B","pubKey":"` + pk + `"}` -raw1byte := "04" + "00" + "aabb" // pathByte=0x00 → hashSize=1 (direct send, no hops) -raw2byte := "04" + "40" + "aabb" // pathByte=0x40 → hashSize=2 +raw1byte := "11" + "01" + "aabb" // FLOOD, pathByte=0x01 → hashSize=1 +raw2byte := "11" + "41" + "aabb" // FLOOD, pathByte=0x41 → hashSize=2 payloadType := 4 // 1 packet with hashSize=1, 4 packets with hashSize=2 (latest is 2-byte) @@ -2310,8 +2310,8 @@ func TestGetNodeHashSizeInfoLatestWins(t *testing.T) { db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, role) VALUES (?, 'LatestWins', 'repeater')", pk) decoded := `{"name":"LatestWins","pubKey":"` + pk + `"}` - raw1byte := "04" + "00" + "aabb" // pathByte=0x00 → hashSize=1 - raw2byte := "04" + "40" + "aabb" // pathByte=0x40 → hashSize=2 + raw1byte := "11" + "01" + "aabb" // FLOOD, pathByte=0x01 → hashSize=1 + raw2byte := "11" + "41" + "aabb" // FLOOD, pathByte=0x41 → hashSize=2 payloadType := 4 // 4 historical 1-byte adverts, then 1 recent 2-byte advert (latest). diff --git a/cmd/server/store.go b/cmd/server/store.go index e92a1738..c9c38ee6 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -5155,16 +5155,26 @@ func (s *PacketStore) computeAnalyticsHashSizes(region string) map[string]interf name = pk } } + // Skip zero-hop direct adverts for hash_size — the + // path byte is locally generated and unreliable. + // Still count the packet and update lastSeen. + isZeroHop := (routeType == uint64(RouteDirect) || routeType == uint64(RouteTransportDirect)) && (actualPathByte&0x3F) == 0 if byNode[pk] == nil { role := nodeRoleByPK[pk] // empty if unknown + initHS := hashSize + if isZeroHop { + initHS = 0 + } byNode[pk] = map[string]interface{}{ - "hashSize": hashSize, "packets": 0, + "hashSize": initHS, "packets": 0, "lastSeen": tx.FirstSeen, "name": name, "role": role, } } byNode[pk]["packets"] = byNode[pk]["packets"].(int) + 1 - byNode[pk]["hashSize"] = hashSize + if !isZeroHop { + byNode[pk]["hashSize"] = hashSize + } byNode[pk]["lastSeen"] = tx.FirstSeen } } @@ -5669,13 +5679,22 @@ func (s *PacketStore) computeNodeHashSizeInfo() map[string]*hashSizeNodeInfo { continue } routeType := int(header & 0x03) - pathByte, err := strconv.ParseUint(tx.RawHex[2:4], 16, 8) + // Transport routes (0, 3) have 4 transport code bytes before the path + // byte, so the path byte is at offset 5 instead of 1. + pbOffset := 1 + if routeType == RouteTransportFlood || routeType == RouteTransportDirect { + pbOffset = 5 + } + if len(tx.RawHex) < (pbOffset+1)*2 { + continue + } + pathByte, err := strconv.ParseUint(tx.RawHex[pbOffset*2:pbOffset*2+2], 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 { + // Direct zero-hop adverts (route types 2 and 3) use path byte 0x00 + // locally and can misreport multibyte hash mode as 1-byte. + if (routeType == RouteDirect || routeType == RouteTransportDirect) && (pathByte&0x3F) == 0 { continue } hs := int((pathByte>>6)&0x3) + 1