Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions cmd/server/coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3341,17 +3341,20 @@ func TestHashSizeTransportDirectZeroHopSkipped(t *testing.T) {
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)
// RouteDirect (2) zero-hop with an all-zero path byte: the sender wiped the
// size bits, so there is nothing to read. Should be skipped.
// (This fixture used to be 0x40. A zero hop count with the size bits SET is
// a deliberate declaration — see TestZeroHopDirectAdvertDeclaredSizeIsEvidence
// — so the skip is keyed on the byte's content, not on the route type.)
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)
VALUES ('1200aabbccdd', '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)
// RouteTransportDirect (3) zero-hop: 4 transport bytes + all-zero path byte
// Should ALSO be skipped (this was the missing case in #747)
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)
VALUES ('130102030400aabb', '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)

Expand Down
49 changes: 35 additions & 14 deletions cmd/server/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -8194,14 +8194,17 @@ func (s *PacketStore) computeAnalyticsHashSizes(region, area string) map[string]
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
// Skip zero-hop direct adverts whose path byte is entirely zero —
// there the size bits were wiped by the sender and say nothing.
// A non-zero byte with a zero hop count (0x40 / 0x80) is a
// deliberate size declaration; keep it. Same rule as
// computeNodeHashSizeInfo. Skipped packets still count and still
// update lastSeen.
isUndeclaredZeroHop := (routeType == uint64(RouteDirect) || routeType == uint64(RouteTransportDirect)) && actualPathByte == 0x00
if byNode[pk] == nil {
role := nodeRoleByPK[pk] // empty if unknown
initHS := hashSize
if isZeroHop {
if isUndeclaredZeroHop {
initHS = 0
}
byNode[pk] = map[string]interface{}{
Expand All @@ -8211,7 +8214,7 @@ func (s *PacketStore) computeAnalyticsHashSizes(region, area string) map[string]
}
}
byNode[pk]["packets"] = byNode[pk]["packets"].(int) + 1
if !isZeroHop {
if !isUndeclaredZeroHop {
byNode[pk]["hashSize"] = hashSize
}
byNode[pk]["lastSeen"] = tx.FirstSeen
Expand Down Expand Up @@ -8809,11 +8812,25 @@ func (s *PacketStore) computeNodeHashSizeInfo() map[string]*hashSizeNodeInfo {
if err != nil {
continue
}
// 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 {
// Direct zero-hop adverts carry no path, so the hop count is 0. Whether
// the SIZE bits are meaningful depends on the sender: firmware that
// predates meshcore-dev/MeshCore#3293 does `path_len = 0`, wiping the
// whole byte including the two size bits, so 0x00 says nothing about
// the node's path.hash.mode. A sender that writes the size through
// setPathHashSizeAndCount() emits 0x40 / 0x80 with a zero hop count —
// that is a deliberate declaration and the only way those bits can be
// non-zero here. Skip on the byte's content, not on the route type.
if (routeType == RouteDirect || routeType == RouteTransportDirect) && pathByte == 0x00 {
continue
}
// NOTE: hash_size 4 is reserved and rejected by the decoder (#1211),
// but this function has always reported it (TestHashSizeTransport-
// RoutePathByteOffset pins HashSize=4 for path byte 0xC1), unlike
// computeAnalyticsHashSizes which drops it. Skipping on byte content
// widens that pre-existing gap slightly, from 0xC1 to also 0xC0.
// Adding a `hs > 3` guard here would change the behaviour that test
// deliberately pins, so reconciling the two sites is left as separate
// work rather than smuggled into this fix.
hs := int((pathByte>>6)&0x3) + 1

var d map[string]interface{}
Expand Down Expand Up @@ -8889,13 +8906,17 @@ func (s *PacketStore) computeNodeHashSizeInfo() map[string]*hashSizeNodeInfo {
return info
}

// hashSizeMinObservations is the minimum number of non-zero-hop adverts in the
// window before a node is eligible to be flagged as flip-flopping at all.
// hashSizeMinObservations is the minimum number of size-declaring adverts in
// the window before a node is eligible to be flagged as flip-flopping at all.
// Since #1913 that includes zero-hop direct adverts whose path byte declares a
// size (0x40 / 0x80); only an all-zero path byte is treated as "says nothing".
const hashSizeMinObservations = 3

// hashSizeRecentAgreeCount is how many of the most recent non-zero-hop adverts
// must share a single hash size for a node to be considered "settled", clearing
// its flip-flop ("varies") flag.
// hashSizeRecentAgreeCount is how many of the most recent size-declaring
// adverts must share a single hash size for a node to be considered "settled",
// clearing its flip-flop ("varies") flag. Zero-hop directs that declare a size
// now count here too (#1913), and they are frequent, so a settled node clears
// the "varies" flag sooner than it did before.
const hashSizeRecentAgreeCount = 3

// recentAdvertsAgree reports whether the last n entries of a chronologically
Expand Down
251 changes: 251 additions & 0 deletions cmd/server/zerohop_hashsize_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
package main

import (
"fmt"
"testing"
"time"
)

// Zero-hop direct adverts carry no path, so the hop count is always 0. Whether
// the two SIZE bits next to it mean anything depends on the sender:
//
// - Firmware predating meshcore-dev/MeshCore#3293 does `path_len = 0`, which
// wipes the whole byte. 0x00 therefore says nothing about path.hash.mode
// and must not be read as "1 byte".
// - A sender that writes the size through setPathHashSizeAndCount() emits
// 0x40 (2 bytes) or 0x80 (3 bytes) with a zero hop count. Nothing else can
// produce those bits here, so they are a deliberate declaration.
//
// Before this fix the skip was keyed on the route type and swallowed both
// cases. These tests pin the distinction. The two multi-byte fixtures are real
// packets off the Czech mesh (869.4 MHz), captured 2026-08-24/25.

// insertAdvert adds one ADVERT transmission plus a matching observation.
func insertAdvert(t *testing.T, db *DB, id int, rawHex, hash, pubKey, name string, routeType int, seen string, seenEpoch int64) {
t.Helper()
if _, err := db.conn.Exec(
`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
VALUES (?, ?, ?, ?, 4, ?)`,
rawHex, hash, seen, routeType,
fmt.Sprintf(`{"pubKey":"%s","name":"%s","type":"ADVERT"}`, pubKey, name),
); err != nil {
t.Fatalf("insert transmission %s: %v", hash, err)
}
if _, err := db.conn.Exec(
`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (?, 1, 10.0, -90, '[]', ?)`, id, seenEpoch,
); err != nil {
t.Fatalf("insert observation %s: %v", hash, err)
}
}

func zeroHopStore(t *testing.T) (*DB, string, int64) {
t.Helper()
db := setupTestDB(t)
now := time.Now().UTC().Add(-1 * time.Hour)
recent := now.Format(time.RFC3339)
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
VALUES ('obs1', 'Obs', 'PRG', ?, '2026-01-01T00:00:00Z', 10)`, recent)
return db, recent, now.Unix()
}

// TestZeroHopDirectAdvertDeclaredSizeIsEvidence: a zero-hop DIRECT advert whose
// path byte is 0x40 declares a 2-byte path hash and must be counted.
// Fixture: tth-hrebecna, 2026-08-25T09:29:40Z, header 0x12 (route 2, ADVERT).
func TestZeroHopDirectAdvertDeclaredSizeIsEvidence(t *testing.T) {
db, recent, epoch := zeroHopStore(t)
defer db.Close()

const hrebecnaPK = "5361aa08929b6248bb61cb1fab0fdb97d6dba8ae44f53a44427f63e7798a6b6b"
insertAdvert(t, db, 1,
"1240"+hrebecnaPK+"83608d6a3d38eeb187a94a745daf2ffb3191d1ae49fb82f30a083ba081ffbb20"+
"266331879c931267408acd04bcb8c8aec558d106ab40e942c70ce8e214af68529faa580b9243bd000322a5c300"+
"7474682d6872656265636e61",
"zh_direct_2b", hrebecnaPK, "tth-hrebecna", 2, recent, epoch)

store := NewPacketStore(db, nil)
store.Load()
info := store.GetNodeHashSizeInfo()

ni, ok := info[hrebecnaPK]
if !ok {
t.Fatal("node with a 0x40 zero-hop advert missing from hash size info — the declared size was dropped")
}
if ni.HashSize != 2 {
t.Errorf("want HashSize=2 from path byte 0x40, got %d", ni.HashSize)
}
if ni.Inconsistent {
t.Error("single advert must not be flagged inconsistent")
}
}

// TestZeroHopDirectAdvertThreeByteDeclaration: same rule at 3 bytes (0x80).
// Fixture: TTH-L1, 2026-08-24T15:36:45Z.
func TestZeroHopDirectAdvertThreeByteDeclaration(t *testing.T) {
db, recent, epoch := zeroHopStore(t)
defer db.Close()

const pk = "e11e55684170982339efe2266a423c9040f909339646ee2adeea19c21ed2b02a"
insertAdvert(t, db, 1,
"1280"+pk+"0c658c6afcf586f0935c87b563dc0770f1ccb558126d16e4805eb4c556d511bf"+
"c1f5d832a760bbe2f08d73b62c068c1a269425faadb2f575a69bcfc53ebae0cb65b71d0981"+
"5454482d4c31",
"zh_direct_3b", pk, "TTH-L1", 2, recent, epoch)

store := NewPacketStore(db, nil)
store.Load()
info := store.GetNodeHashSizeInfo()

ni, ok := info[pk]
if !ok {
t.Fatal("node with a 0x80 zero-hop advert missing from hash size info")
}
if ni.HashSize != 3 {
t.Errorf("want HashSize=3 from path byte 0x80, got %d", ni.HashSize)
}
}

// TestZeroHopDirectAdvertAllZeroByteStillSkipped: 0x00 carries no information —
// the sender wiped the size bits — so the node must stay absent rather than be
// recorded as 1 byte. This is the behaviour #649 asked for and it must survive.
func TestZeroHopDirectAdvertAllZeroByteStillSkipped(t *testing.T) {
db, recent, epoch := zeroHopStore(t)
defer db.Close()

const pk = "aaaa000000000000000000000000000000000000000000000000000000000001"
insertAdvert(t, db, 1, "1200"+pk+"cafe", "zh_direct_zero", pk, "Legacy-Node", 2, recent, epoch)

store := NewPacketStore(db, nil)
store.Load()

if ni, ok := store.GetNodeHashSizeInfo()[pk]; ok {
t.Errorf("node seen only via a 0x00 zero-hop advert must have no hash size evidence, got %d", ni.HashSize)
}
}

// TestZeroHopTransportDirectDeclaredSize: route type 3 puts the path byte at
// offset 5, behind four transport code bytes. The same content rule applies.
func TestZeroHopTransportDirectDeclaredSize(t *testing.T) {
db, recent, epoch := zeroHopStore(t)
defer db.Close()

const declared = "aaaa000000000000000000000000000000000000000000000000000000000002"
const wiped = "aaaa000000000000000000000000000000000000000000000000000000000003"

// header 0x13 = payload_type 4 (ADVERT), route type 3; 4 transport bytes; path byte 0x40.
insertAdvert(t, db, 1, "1301020304"+"40"+declared+"cafe", "zh_td_2b", declared, "TD-Declared", 3, recent, epoch)
// Same shape, path byte 0x00 → no evidence.
insertAdvert(t, db, 2, "1305060708"+"00"+wiped+"cafe", "zh_td_zero", wiped, "TD-Wiped", 3, recent, epoch)

store := NewPacketStore(db, nil)
store.Load()
info := store.GetNodeHashSizeInfo()

if ni, ok := info[declared]; !ok {
t.Error("transport-direct node with 0x40 at offset 5 missing from hash size info")
} else if ni.HashSize != 2 {
t.Errorf("transport-direct: want HashSize=2, got %d", ni.HashSize)
}
if _, ok := info[wiped]; ok {
t.Error("transport-direct node with an all-zero path byte must have no evidence")
}
}

// TestZeroHopDeclaredSizeReachesMultiByteCapability: the capability view derives
// "confirmed" from advert evidence, so a node heard only via zero-hop adverts
// should now be confirmed rather than left unknown. This is the user-visible
// half — it is what the map's multi-byte overlay reads.
func TestZeroHopDeclaredSizeReachesMultiByteCapability(t *testing.T) {
db, recent, epoch := zeroHopStore(t)
defer db.Close()

const pk = "5361aa08929b6248bb61cb1fab0fdb97d6dba8ae44f53a44427f63e7798a6b6b"
db.conn.Exec(`INSERT INTO nodes (public_key, name, role, last_seen, first_seen, advert_count)
VALUES (?, 'tth-hrebecna', 'repeater', ?, ?, 1)`, pk, recent, recent)
insertAdvert(t, db, 1, "1240"+pk+"cafe", "zh_cap", pk, "tth-hrebecna", 2, recent, epoch)

store := NewPacketStore(db, nil)
store.Load()

var found *MultiByteCapEntry
for _, e := range store.computeMultiByteCapability(nil) {
if e.PublicKey == pk {
entry := e
found = &entry
break
}
}
if found == nil {
t.Fatal("node missing from multi-byte capability output")
}
if found.Status != "confirmed" {
t.Errorf("want status=confirmed from a declared zero-hop advert, got %q (evidence %q)", found.Status, found.Evidence)
}
if found.MaxHashSize != 2 {
t.Errorf("want MaxHashSize=2, got %d", found.MaxHashSize)
}
}

// #1913: the analytics aggregate must honour a DECLARED size on a zero-hop
// direct advert, not just the node-info path. Without this, reverting the
// computeAnalyticsHashSizes half of the fix passed the entire suite — the
// only analytics zero-hop test (TestAnalyticsHashSizesZeroHopSkip) uses path
// byte 0x00, which both the old and the new rule skip, so it cannot tell the
// two rules apart.
func TestAnalyticsHashSizesZeroHopDeclaredSizeCounts(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)

// declared: only ever sends zero-hop direct adverts, but declares 3-byte
// mode in the path byte (0x80 = size bits 10 → 3, hop count 0).
declared := "dddd000000000001"
// wiped: same shape, but path byte 0x00 — the pre-#3293 firmware that
// erases the size bits. It must stay unknown, not become 1-byte.
wiped := "dddd000000000002"
for pk, name := range map[string]string{declared: "ZH-Declared", wiped: "ZH-Wiped"} {
db.conn.Exec(`INSERT INTO nodes (public_key, name, role) VALUES (?, ?, 'repeater')`, pk, name)
}

decodedFor := func(pk, name string) string {
return `{"pubKey":"` + pk + `","name":"` + name + `","type":"ADVERT"}`
}
// 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 ('1280aabbccdd', 'zh_declared', ?, 2, 4, ?)`, recent, decodedFor(declared, "ZH-Declared"))
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 1, 10.0, -90, '[]', ?)`, recentEpoch)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
VALUES ('1200aabbccdd', 'zh_wiped', ?, 2, 4, ?)`, recent, decodedFor(wiped, "ZH-Wiped"))
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("", "")

multiByteNodes, ok := result["multiByteNodes"].([]map[string]interface{})
if !ok {
t.Fatal("expected multiByteNodes slice in analytics hash sizes")
}
sizes := map[string]int{}
for _, n := range multiByteNodes {
if pk, _ := n["pubkey"].(string); pk != "" {
if hs, ok := n["hashSize"].(int); ok {
sizes[pk] = hs
}
}
}
if got := sizes[declared]; got != 3 {
t.Errorf("zero-hop advert declaring 3-byte mode: analytics hashSize = %d, want 3", got)
}
if got, present := sizes[wiped]; present {
t.Errorf("zero-hop advert with an all-zero path byte must stay unknown, got hashSize = %d", got)
}
}
Loading