diff --git a/.eslintrc.json b/.eslintrc.json index ab6e2ded7..5ee0d9832 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -256,6 +256,7 @@ "pad3": "readonly", "pages": "readonly", "parseViewportHash": "readonly", + "pathHashSize": "readonly", "payloadTypeColor": "readonly", "payloadTypeName": "readonly", "process": "readonly", diff --git a/cmd/server/channel_message_hash_size_test.go b/cmd/server/channel_message_hash_size_test.go new file mode 100644 index 000000000..6a53a345a --- /dev/null +++ b/cmd/server/channel_message_hash_size_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "encoding/json" + "testing" + "time" +) + +// The Channels view shows the path hash size (1-3 bytes) each message was +// sent with, as path_hash_size (not hash_size: nodes already use that key for +// the size a node is observed to use). /api/channels/{hash}/messages is served +// by the DB query when a DB is attached and by the in-memory store otherwise, +// and the DB query has a v3 and a pre-v3 shape, so all three must carry it. +// 0 = the packet does not encode one (see packetpath.HashSize). + +var chHashSizeWant = map[string]float64{ + "dddddddddddddd01": 1, // flood, 1-byte, 2 hops + "dddddddddddddd02": 2, // flood, 2-byte, heard direct (0 hops) + "dddddddddddddd03": 3, // transport flood, 3-byte, 1 hop + "dddddddddddddd04": 0, // direct zero-hop: no size encoded +} + +func setupChannelHashSizeDB(t *testing.T) *DB { + t.Helper() + return seedChannelHashSize(t, setupTestDB(t)) +} + +// seedChannelHashSize writes one channel message per chHashSizeWant entry, each +// heard by one observation, in the observation shape of db's schema. +func seedChannelHashSize(t *testing.T, db *DB) *DB { + t.Helper() + if _, err := db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs1', 'Observer One', 'BRU')`); err != nil { + t.Fatalf("insert observer: %v", err) + } + now := time.Now().UTC() + rows := []struct { + hash, rawHex string + routeType int + }{ + {"dddddddddddddd01", "1502AABBDEADBEEF", 1}, + {"dddddddddddddd02", "1540DEADBEEF", 1}, + {"dddddddddddddd03", "141122334481AABBCCDEADBEEF", 0}, + {"dddddddddddddd04", "1600DEADBEEF", 2}, + } + for i, r := range rows { + ts := now.Add(time.Duration(i-len(rows)) * time.Minute) + res, err := db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, channel_hash) + VALUES (?, ?, ?, ?, 5, '{"type":"CHAN","channel":"#hashsize","text":"Alice: msg"}', '#hashsize')`, + r.rawHex, r.hash, ts.Format(time.RFC3339), r.routeType) + if err != nil { + t.Fatalf("insert tx %s: %v", r.hash, err) + } + txID, _ := res.LastInsertId() + obsSQL := `INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (?, 1, 9.5, -90, '[]', ?)` + if !db.isV3 { + obsSQL = `INSERT INTO observations (transmission_id, observer_id, observer_name, snr, rssi, path_json, timestamp) + VALUES (?, 'obs1', 'Observer One', 9.5, -90, '[]', ?)` + } + if _, err := db.conn.Exec(obsSQL, txID, ts.Unix()); err != nil { + t.Fatalf("insert obs %s: %v", r.hash, err) + } + } + return db +} + +func assertChannelMessageHashSizes(t *testing.T, messages []map[string]interface{}) { + t.Helper() + if len(messages) != len(chHashSizeWant) { + t.Fatalf("expected %d messages, got %d", len(chHashSizeWant), len(messages)) + } + for _, m := range messages { + // Compare on what the browser receives, not on the Go value type. + b, err := json.Marshal(m) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded map[string]interface{} + if err := json.Unmarshal(b, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + h, _ := decoded["packetHash"].(string) + want, known := chHashSizeWant[h] + if !known { + t.Errorf("unexpected packetHash %q", h) + continue + } + got, present := decoded["path_hash_size"] + if !present { + t.Errorf("%s: path_hash_size key missing", h) + continue + } + if got != want { + t.Errorf("%s: path_hash_size = %#v, want %v", h, got, want) + } + } +} + +func TestDBGetChannelMessagesCarriesHashSize(t *testing.T) { + db := setupChannelHashSizeDB(t) + defer db.Close() + messages, _, err := db.GetChannelMessages("#hashsize", 100, 0) + if err != nil { + t.Fatal(err) + } + assertChannelMessageHashSizes(t, messages) +} + +func TestDBGetChannelMessagesCarriesHashSizeV2Schema(t *testing.T) { + db := seedChannelHashSize(t, setupTestDBv2(t)) + defer db.Close() + if db.isV3 { + t.Fatal("precondition: setupTestDBv2 must give the pre-v3 schema") + } + messages, _, err := db.GetChannelMessages("#hashsize", 100, 0) + if err != nil { + t.Fatal(err) + } + assertChannelMessageHashSizes(t, messages) +} + +func TestStoreGetChannelMessagesCarriesHashSize(t *testing.T) { + db := setupChannelHashSizeDB(t) + defer db.Close() + store := NewPacketStore(db, nil) + if err := store.Load(); err != nil { + t.Fatalf("store.Load: %v", err) + } + messages, _ := store.GetChannelMessages("#hashsize", 100, 0) + assertChannelMessageHashSizes(t, messages) +} diff --git a/cmd/server/db.go b/cmd/server/db.go index e6f1a3dd3..d1d24e28c 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -17,6 +17,7 @@ import ( _ "github.com/mattn/go-sqlite3" "github.com/meshcore-analyzer/dbschema" "github.com/meshcore-analyzer/geofilter" + "github.com/meshcore-analyzer/packetpath" "golang.org/x/sync/singleflight" ) @@ -2219,9 +2220,11 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . if db.hasScopeName { scopeNameCol = ", t.scope_name" } + // substr(t.raw_hex, 1, 12): packetpath.HashSize only reads the header, + // transport codes and path byte (6 bytes), not the whole packet. var obsSQL string if db.isV3 { - obsSQL = `SELECT o.id, t.id, t.hash, t.decoded_json, t.first_seen, + obsSQL = `SELECT o.id, t.id, t.hash, t.decoded_json, t.first_seen, substr(t.raw_hex, 1, 12), obs.id, obs.name, o.snr, o.path_json, o.timestamp` + scopeNameCol + ` FROM observations o JOIN transmissions t ON t.id = o.transmission_id @@ -2229,7 +2232,7 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . WHERE t.id IN (` + strings.Join(idPlaceholders, ",") + `) ORDER BY o.id ASC` } else { - obsSQL = `SELECT o.id, t.id, t.hash, t.decoded_json, t.first_seen, + obsSQL = `SELECT o.id, t.id, t.hash, t.decoded_json, t.first_seen, substr(t.raw_hex, 1, 12), o.observer_id, o.observer_name, o.snr, o.path_json, o.timestamp` + scopeNameCol + ` FROM observations o JOIN transmissions t ON t.id = o.transmission_id @@ -2252,11 +2255,11 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . for rows.Next() { var pktID, txID int - var pktHash, dj, fs, obsID, obsName, pathJSON sql.NullString + var pktHash, dj, fs, rawHexHead, obsID, obsName, pathJSON sql.NullString var snr sql.NullFloat64 var obsTs sql.NullInt64 var scopeName sql.NullString - scanArgs := []interface{}{&pktID, &txID, &pktHash, &dj, &fs, &obsID, &obsName, &snr, &pathJSON, &obsTs} + scanArgs := []interface{}{&pktID, &txID, &pktHash, &dj, &fs, &rawHexHead, &obsID, &obsName, &snr, &pathJSON, &obsTs} if db.hasScopeName { scanArgs = append(scanArgs, &scopeName) } @@ -2312,6 +2315,7 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . "hops": hops, "snr": nullFloat(snr), "scope_name": nullStr(scopeName), + "path_hash_size": packetpath.HashSize(rawHexHead.String), }, Repeats: 1, } diff --git a/cmd/server/path_hash_size_agreement_test.go b/cmd/server/path_hash_size_agreement_test.go new file mode 100644 index 000000000..b6d2e633b --- /dev/null +++ b/cmd/server/path_hash_size_agreement_test.go @@ -0,0 +1,62 @@ +package main + +import ( + "encoding/json" + "os" + "testing" + + "github.com/meshcore-analyzer/packetpath" +) + +// The server reads a packet's path hash size two ways: packetpath.HashSize +// (channel messages' path_hash_size) and the decoder's path.hashSize (shipped +// in every decoded_json). They must agree, or the Channels view and the +// packet detail can show different sizes for the same packet. The cases are +// the ones shared with public/app.js pathHashSize, so this test also runs that +// table in CI (internal/packetpath has no CI job of its own). +func TestPathHashSizeAgreesWithDecoder(t *testing.T) { + data, err := os.ReadFile("../../test-fixtures/path-hash-size-cases.json") + if err != nil { + t.Fatalf("read shared cases: %v", err) + } + var fixture struct { + Cases []struct { + Name string `json:"name"` + Raw string `json:"raw"` + Want int `json:"want"` + } `json:"cases"` + } + if err := json.Unmarshal(data, &fixture); err != nil { + t.Fatalf("parse shared cases: %v", err) + } + if len(fixture.Cases) == 0 { + t.Fatal("shared cases file has no cases") + } + + compared := 0 + for _, c := range fixture.Cases { + if got := packetpath.HashSize(c.Raw); got != c.Want { + t.Errorf("%s: packetpath.HashSize(%q) = %d, want %d", c.Name, c.Raw, got, c.Want) + } + // The decoder needs a whole payload; the shared cases only carry the + // header and path, so pad them. Inputs it rejects (reserved size bits, + // truncated or invalid hex) have no decoder value to compare. + raw := c.Raw + repeatHex("AA", 40) + pkt, err := DecodePacket(raw, false) + if err != nil { + continue + } + // TRACE path bytes are SNR readings; the decoder's hashSize for TRACE + // comes from the trace flags and is not a path hash size. + if pkt.Header.PayloadType == PayloadTRACE { + continue + } + compared++ + if helper := packetpath.HashSize(raw); pkt.Path.HashSize != helper { + t.Errorf("%s: decoder path.hashSize = %d, packetpath.HashSize = %d", c.Name, pkt.Path.HashSize, helper) + } + } + if compared == 0 { + t.Fatal("no shared case was decodable; the agreement check compared nothing") + } +} diff --git a/cmd/server/store.go b/cmd/server/store.go index df94d8c61..4f7fa4a37 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -18,6 +18,7 @@ import ( "unicode/utf8" "github.com/meshcore-analyzer/mbcapqueue" + "github.com/meshcore-analyzer/packetpath" "golang.org/x/sync/singleflight" ) @@ -5759,6 +5760,7 @@ func (s *PacketStore) GetChannelMessages(channelHash string, limit, offset int, "hops": hops, "snr": snrVal, "scope_name": strPtrOrNil(tx.ScopeName), + "path_hash_size": packetpath.HashSize(tx.RawHex), }, Repeats: 1, Observers: observers, diff --git a/internal/packetpath/path.go b/internal/packetpath/path.go index 8ca0ef78f..7b990c71c 100644 --- a/internal/packetpath/path.go +++ b/internal/packetpath/path.go @@ -52,6 +52,52 @@ func DecodePathFromRawHex(rawHex string) ([]string, error) { return hops, nil } +// HashSize returns the path hash size (1-3 bytes) the originator chose, read +// from the path byte of raw_hex, or 0 when the packet does not carry one. +// +// A flood packet always carries it: Mesh::sendFlood sets the path byte to +// (size-1)<<6 before the first hop, so it holds even at 0 hops. A direct +// packet with no hops carries none: Mesh::sendZeroHop writes 0x00, and a +// direct path that has run out of hops has no hash left to size. That is the +// same rule cmd/server/decoder.go applies to path.hashSize. TRACE path bytes +// are SNR readings, not hashes. Only the first few bytes are parsed, so this +// is cheap on hot paths. +func HashSize(rawHex string) int { + header, ok := hexByteAt(rawHex, 0) + if !ok || !PathBytesAreHops(header>>2&0x0F) { + return 0 + } + routeType := int(header & 0x03) + offset := 1 + if IsTransportRoute(routeType) { + offset += 4 + } + pathByte, ok := hexByteAt(rawHex, offset) + if !ok { + return 0 + } + if pathByte&0x3F == 0 && (routeType == RouteDirect || routeType == RouteTransportDirect) { + return 0 + } + size := int(pathByte>>6) + 1 + if size > 3 { + return 0 // 0b11 is reserved; firmware rejects sizes above 3 + } + return size +} + +// hexByteAt decodes the byte at byte index i of a hex string. +func hexByteAt(s string, i int) (byte, bool) { + if len(s) < 2*i+2 { + return 0, false + } + b, err := hex.DecodeString(s[2*i : 2*i+2]) + if err != nil { + return 0, false + } + return b[0], true +} + // DecodeHopsForPayload returns the header path hops only when the payload type's // header bytes are actually route hops (i.e. PathBytesAreHops(payloadType) is true). // For TRACE packets it returns (nil, ErrPayloadHasNoHeaderHops) so the caller is diff --git a/internal/packetpath/path_test.go b/internal/packetpath/path_test.go index c8c842e2f..ddf9d5c3f 100644 --- a/internal/packetpath/path_test.go +++ b/internal/packetpath/path_test.go @@ -3,6 +3,7 @@ package packetpath import ( "encoding/hex" "encoding/json" + "os" "strings" "testing" ) @@ -148,3 +149,31 @@ func TestDecodeHopsForPayload_TraceReturnsError(t *testing.T) { t.Errorf("expected nil hops for TRACE, got %v", hops) } } + +// TestHashSize runs the cases shared with pathHashSize in public/app.js +// (tests/unit/test-frontend-helpers.js reads the same file), so the Go and JS +// implementations cannot drift apart without one of the two suites failing. +func TestHashSize(t *testing.T) { + data, err := os.ReadFile("../../test-fixtures/path-hash-size-cases.json") + if err != nil { + t.Fatalf("read shared cases: %v", err) + } + var fixture struct { + Cases []struct { + Name string `json:"name"` + Raw string `json:"raw"` + Want int `json:"want"` + } `json:"cases"` + } + if err := json.Unmarshal(data, &fixture); err != nil { + t.Fatalf("parse shared cases: %v", err) + } + if len(fixture.Cases) == 0 { + t.Fatal("shared cases file has no cases") + } + for _, c := range fixture.Cases { + if got := HashSize(c.Raw); got != c.Want { + t.Errorf("%s: HashSize(%q) = %d, want %d", c.Name, c.Raw, got, c.Want) + } + } +} diff --git a/public/app.js b/public/app.js index 28b97b399..1e0801c0d 100644 --- a/public/app.js +++ b/public/app.js @@ -12,6 +12,29 @@ function payloadTypeColor(n) { return PAYLOAD_COLORS[n] || 'unknown'; } function isTransportRoute(rt) { return rt === 0 || rt === 3; } /** Byte offset of path_len in raw_hex: 5 for transport routes (4 bytes of next/last hop codes precede it), 1 otherwise. */ function getPathLenOffset(routeType) { return isTransportRoute(routeType) ? 5 : 1; } +/** + * Path hash size (1-3 bytes) the originator chose, from raw_hex's path byte, or + * 0 when the packet carries none. Same rule as packetpath.HashSize on the + * server; both run test-fixtures/path-hash-size-cases.json. A flood packet + * always encodes it (firmware sendFlood sets it before the first hop), a direct + * packet with no hops does not (sendZeroHop writes 0x00, and an exhausted + * direct path has no hash left to size), and TRACE path bytes are SNR readings. + */ +function pathHashSize(rawHex) { + // Names as in internal/packetpath/route.go. + const PAYLOAD_TRACE = 9, ROUTE_DIRECT = 2, ROUTE_TRANSPORT_DIRECT = 3; + if (typeof rawHex !== 'string' || !/^[0-9a-f]{2}/i.test(rawHex)) return 0; + const header = parseInt(rawHex.slice(0, 2), 16); + if (((header >> 2) & 0x0F) === PAYLOAD_TRACE) return 0; + const routeType = header & 0x03; + const off = getPathLenOffset(routeType) * 2; + const pathHex = rawHex.slice(off, off + 2); + if (!/^[0-9a-f]{2}$/i.test(pathHex)) return 0; + const pathByte = parseInt(pathHex, 16); + if ((pathByte & 0x3F) === 0 && (routeType === ROUTE_DIRECT || routeType === ROUTE_TRANSPORT_DIRECT)) return 0; + const size = (pathByte >> 6) + 1; + return size > 3 ? 0 : size; +} function transportBadge(rt) { return isTransportRoute(rt) ? ' T' : ''; } /** diff --git a/public/channels.js b/public/channels.js index 964bfa876..0d7c0cc38 100644 --- a/public/channels.js +++ b/public/channels.js @@ -593,10 +593,11 @@ // M5: Cache invalidation — if total candidate count changed, re-decrypt everything var totalCandidates = candidates.length; - // #1851: a cache written before messages carried scope_name would keep - // those messages chipless on the delta path, so decrypt them again. - var cacheLacksScope = cachedMsgs.some(function (m) { return !('scope_name' in m); }); - var needFullDecrypt = (totalCandidates !== cachedCount) || opts.forceFullDecrypt || cacheLacksScope; + // #1851: a cache written before messages carried scope_name (or + // path_hash_size) would keep those messages without it on the delta path, + // so decrypt them again. + var cacheLacksFields = cachedMsgs.some(function (m) { return !('scope_name' in m) || !('path_hash_size' in m); }); + var needFullDecrypt = (totalCandidates !== cachedCount) || opts.forceFullDecrypt || cacheLacksFields; // M5: Delta fetch — only decrypt packets newer than lastTs if (!needFullDecrypt && cachedMsgs.length > 0 && lastTs) { @@ -681,6 +682,7 @@ hops: d.path_len || 0, snr: c.packet.snr || null, observers: c.packet.observer_name ? [c.packet.observer_name] : [], scope_name: c.packet.scope_name ?? null, + path_hash_size: pathHashSize(c.packet.raw_hex), repeats: 1 }); continue; @@ -698,6 +700,7 @@ hops: 0, snr: c.packet.snr || null, observers: c.packet.observer_name ? [c.packet.observer_name] : [], scope_name: c.packet.scope_name ?? null, + path_hash_size: pathHashSize(c.packet.raw_hex), repeats: 1 }); } else { @@ -1438,6 +1441,7 @@ var observer = m.data?.packet?.observer_name || m.data?.observer || null; // ?? not ||: '' (transport-scoped, region unmatched) must survive. var scopeName = m.data?.scope_name ?? m.data?.packet?.scope_name ?? null; + var hashSize = pathHashSize(m.data?.raw_hex ?? m.data?.packet?.raw_hex); // Update channel list entry — only once per unique packet hash var isFirstObservation = pktHash && !seenHashes.has(pktHash + ':' + channelKey); @@ -1490,6 +1494,7 @@ hops: payload.path_len || 0, snr: snr, scope_name: scopeName, + path_hash_size: hashSize, // #1498: mark as WS-pushed so a later REST replacement // (selectChannel / refreshMessages) can merge instead of // stomp. Without this flag the REST response wipes any @@ -2280,6 +2285,9 @@ if (msg.observers?.length > 1) meta.push(`${msg.observers.length} observers`); if (msg.hops > 0) meta.push(`${msg.hops} hops`); if (msg.snr !== null && msg.snr !== undefined) meta.push(`SNR ${msg.snr}`); + // Cast first: 0, missing or non-numeric all mean the packet encodes no size. + const hs = Number(msg.path_hash_size) || 0; + if (hs) meta.push(`${hs}-byte${hs !== 1 ? 's' : ''}`); const scopeChip = messageScopeChipHtml(msg.scope_name); if (scopeChip) meta.push(scopeChip); diff --git a/public/packets.js b/public/packets.js index 191ebed2c..3c3e3b714 100644 --- a/public/packets.js +++ b/public/packets.js @@ -3361,10 +3361,8 @@ } catch {} } - // Parse hash size from path byte - const plOff = getPathLenOffset(pkt.route_type); - const rawPathByte = pkt.raw_hex ? parseInt(pkt.raw_hex.slice(plOff * 2, plOff * 2 + 2), 16) : NaN; - const hashSize = (isNaN(rawPathByte) || (rawPathByte & 0x3F) === 0) ? null : ((rawPathByte >> 6) + 1); + // Same rule as the Channels view: a flood heard at 0 hops still has a size. + const hashSize = pathHashSize(pkt.raw_hex); const size = effectivePkt.raw_hex ? Math.floor(effectivePkt.raw_hex.length / 2) : (pkt.raw_hex ? Math.floor(pkt.raw_hex.length / 2) : 0); const typeName = payloadTypeName(pkt.payload_type); @@ -3745,7 +3743,15 @@ const pathByte0 = parseInt(buf.slice(off * 2, off * 2 + 2), 16); const hashSizeVal = isNaN(pathByte0) ? '?' : ((pathByte0 >> 6) + 1); const hashCountVal = isNaN(pathByte0) ? '?' : (pathByte0 & 0x3F); - rows += fieldRow(off, 'Path Length', '0x' + (buf.slice(off * 2, off * 2 + 2) || '??'), hashCountVal === 0 ? `hash_count=0 (direct advert)` : `hash_size=${hashSizeVal} byte${hashSizeVal !== 1 ? 's' : ''}, hash_count=${hashCountVal}`); + // At 0 hops the size bits mean something only on a flood (sendFlood sets + // them before the first hop); pathHashSize holds that rule for every page. + const encodedHashSize = pathHashSize(buf); + const pathLenDesc = hashCountVal !== 0 + ? `hash_size=${hashSizeVal} byte${hashSizeVal !== 1 ? 's' : ''}, hash_count=${hashCountVal}` + : (encodedHashSize + ? `hash_size=${encodedHashSize} byte${encodedHashSize !== 1 ? 's' : ''}, hash_count=0` + : 'hash_count=0 (no hash size encoded)'); + rows += fieldRow(off, 'Path Length', '0x' + (buf.slice(off * 2, off * 2 + 2) || '??'), pathLenDesc); off += 1; // Path — render hops from path_json (what this observation reported). @@ -3777,7 +3783,7 @@ rows += sectionRow('Payload — ' + payloadTypeName(pkt.payload_type), 'section-payload'); if (decoded.type === 'ADVERT') { - if (hashCountVal !== 0) rows += fieldRow(pathLenOffset, 'Advertised Hash Size', hashSizeVal + ' byte' + (hashSizeVal !== 1 ? 's' : ''), 'From path byte 0x' + (buf.slice(pathLenOffset * 2, pathLenOffset * 2 + 2) || '??') + ' — bits 7-6 = ' + (hashSizeVal - 1)); + if (encodedHashSize) rows += fieldRow(pathLenOffset, 'Advertised Hash Size', encodedHashSize + ' byte' + (encodedHashSize !== 1 ? 's' : ''), 'From path byte 0x' + (buf.slice(pathLenOffset * 2, pathLenOffset * 2 + 2) || '??') + ' — bits 7-6 = ' + (encodedHashSize - 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), ''); diff --git a/test-all.sh b/test-all.sh index 889327026..fa6a5df84 100755 --- a/test-all.sh +++ b/test-all.sh @@ -132,6 +132,7 @@ node tests/unit/test-issue-1849-trace-hashbytes.js node tests/unit/test-issue-1851-channel-message-scope.js node tests/unit/test-issue-1862-map-region-filter.js node tests/unit/test-issue-1868-control-decode.js +node tests/unit/test-packet-detail-hash-size.js node tests/unit/test-issue-1890-og-url.js node tests/unit/test-issue-1956-release-routing.js node tests/unit/test-issue-1979-scope-adverts-by-role.js diff --git a/test-fixtures/path-hash-size-cases.json b/test-fixtures/path-hash-size-cases.json new file mode 100644 index 000000000..840b10343 --- /dev/null +++ b/test-fixtures/path-hash-size-cases.json @@ -0,0 +1,22 @@ +{ + "_comment": "Shared cases for packetpath.HashSize (internal/packetpath/path_test.go) and pathHashSize (public/app.js, tests/unit/test-frontend-helpers.js). Both must return want for raw; 0 means the packet carries no size. Header byte = (payload_type << 2) | route_type; GRP_TXT=5, TRACE=9.", + "cases": [ + { "name": "flood 1-byte, 2 hops", "raw": "1502ABCDDEADBEEF", "want": 1 }, + { "name": "flood 2-byte, 1 hop", "raw": "1541ABCDDEADBEEF", "want": 2 }, + { "name": "flood 3-byte, 0 hops (heard direct)", "raw": "1580DEADBEEF", "want": 3 }, + { "name": "flood 1-byte, 0 hops (heard direct)", "raw": "1500DEADBEEF", "want": 1 }, + { "name": "transport flood 2-byte, 0 hops", "raw": "141122334440DEADBEEF", "want": 2 }, + { "name": "direct 2-byte, 1 hop", "raw": "1641ABCDDEADBEEF", "want": 2 }, + { "name": "direct zero-hop: size never encoded", "raw": "1600DEADBEEF", "want": 0 }, + { "name": "direct, 0 hops, size bits set: no hops, no size", "raw": "1640DEADBEEF", "want": 0 }, + { "name": "transport direct zero-hop", "raw": "171122334400DEADBEEF", "want": 0 }, + { "name": "transport direct, 0 hops, size bits set", "raw": "171122334480DEADBEEF", "want": 0 }, + { "name": "reserved size bits 0b11", "raw": "15C1ABCDEF01DEADBEEF", "want": 0 }, + { "name": "trace: path bytes are SNR", "raw": "2542ABCDDEADBEEF", "want": 0 }, + { "name": "lowercase hex", "raw": "1541abcd", "want": 2 }, + { "name": "too short", "raw": "15", "want": 0 }, + { "name": "transport too short", "raw": "141122", "want": 0 }, + { "name": "invalid hex", "raw": "ZZ41", "want": 0 }, + { "name": "empty", "raw": "", "want": 0 } + ] +} diff --git a/tests/unit/test-channel-live-decrypt-userprefix.js b/tests/unit/test-channel-live-decrypt-userprefix.js index eaffdc412..c565549fa 100644 --- a/tests/unit/test-channel-live-decrypt-userprefix.js +++ b/tests/unit/test-channel-live-decrypt-userprefix.js @@ -184,6 +184,7 @@ async function run() { ctx.formatHashHex = (h) => String(h); ctx.formatSecondsAgo = () => ''; ctx.payloadTypeName = () => 'GRP_TXT'; + ctx.pathHashSize = () => null; ctx.RegionFilter = { init() {}, onChange(fn) { return () => {}; }, diff --git a/tests/unit/test-frontend-helpers.js b/tests/unit/test-frontend-helpers.js index ad54cb4f2..5bad4c138 100644 --- a/tests/unit/test-frontend-helpers.js +++ b/tests/unit/test-frontend-helpers.js @@ -104,6 +104,19 @@ function loadInCtx(ctx, file) { } } +// The real pathHashSize from app.js, for sandboxes that stub app.js piece by +// piece (loading app.js into them would replace their api/registerPage stubs). +let _appPathHashSize = null; +function realPathHashSize() { + if (!_appPathHashSize) { + const appCtx = makeSandbox(); + loadInCtx(appCtx, 'public/roles.js'); + loadInCtx(appCtx, 'public/app.js'); + _appPathHashSize = appCtx.pathHashSize; + } + return _appPathHashSize; +} + // ===== APP.JS TESTS ===== console.log('\n=== app.js: timeAgo ==='); { @@ -236,6 +249,25 @@ console.log('\n=== app.js: routeTypeName / payloadTypeName ==='); test('getPathLenOffset: direct route (2) → 1', () => assert.strictEqual(ctx.getPathLenOffset(2), 1)); } +console.log('\n=== app.js: pathHashSize ==='); +{ + const ctx = makeSandbox(); + loadInCtx(ctx, 'public/roles.js'); + loadInCtx(ctx, 'public/app.js'); + + // Cases shared with packetpath.HashSize (internal/packetpath/path_test.go, + // cmd/server/path_hash_size_agreement_test.go): the two implementations + // cannot drift apart without one of the suites failing. 0 = no size. + const shared = JSON.parse(fs.readFileSync('test-fixtures/path-hash-size-cases.json', 'utf8')).cases; + test('pathHashSize: shared cases file is not empty', () => assert.ok(shared.length > 0)); + for (const c of shared) { + test('pathHashSize: ' + c.name, () => assert.strictEqual(ctx.pathHashSize(c.raw), c.want)); + } + // Inputs only JavaScript can be handed: a message without raw_hex. + test('pathHashSize: null → 0', () => assert.strictEqual(ctx.pathHashSize(null), 0)); + test('pathHashSize: undefined → 0', () => assert.strictEqual(ctx.pathHashSize(undefined), 0)); +} + console.log('\n=== app.js: scopeCellHtml ==='); { const ctx = makeSandbox(); @@ -2654,6 +2686,7 @@ console.log('\n=== channels.js: WS batch + region snapshot integration ==='); ctx.atob = (s) => Buffer.from(String(s), 'base64').toString('utf8'); ctx.crypto = { subtle: require('crypto').webcrypto.subtle }; ctx.TextEncoder = TextEncoder; ctx.TextDecoder = TextDecoder; ctx.Uint8Array = Uint8Array; + ctx.pathHashSize = realPathHashSize(); loadInCtx(ctx, 'public/channel-decrypt.js'); loadInCtx(ctx, 'public/channels.js'); ctx._pageHandlers.init(appEl); @@ -5649,6 +5682,7 @@ console.log('\n=== packets.js: buildFieldTable transport offsets (#765) ==='); ftCtx.window.isTransportRoute = ftCtx.isTransportRoute; ftCtx.getPathLenOffset = (rt) => ftCtx.isTransportRoute(rt) ? 5 : 1; ftCtx.window.getPathLenOffset = ftCtx.getPathLenOffset; + ftCtx.pathHashSize = ftCtx.window.pathHashSize = realPathHashSize(); loadInCtx(ftCtx, 'public/packets.js'); const { buildFieldTable, fieldRow } = ftCtx.window._packetsTestAPI; @@ -5739,6 +5773,7 @@ console.log('\n=== packets.js: buildFieldTable hop count from path_len (#844) == ftCtx.window.isTransportRoute = ftCtx.isTransportRoute; ftCtx.getPathLenOffset = (rt) => ftCtx.isTransportRoute(rt) ? 5 : 1; ftCtx.window.getPathLenOffset = ftCtx.getPathLenOffset; + ftCtx.pathHashSize = ftCtx.window.pathHashSize = realPathHashSize(); loadInCtx(ftCtx, 'public/packets.js'); const { buildFieldTable } = ftCtx.window._packetsTestAPI; @@ -5771,13 +5806,14 @@ console.log('\n=== packets.js: buildFieldTable hop count from path_len (#844) == 'Public Key should be at offset 6'); }); - test('#844: hashCountVal=0 (direct advert) skips Path section', () => { - // path_len = 0x00 → hash_size=1, hash_count=0 + test('#844: hashCountVal=0 skips Path section', () => { + // path_len = 0x00 on a FLOOD (header 0x11) → hash_size=1, hash_count=0: + // the sender's size is still encoded, there are just no hops to list. const raw = '1100' + '0'.repeat(200); const pkt = { raw_hex: raw, route_type: 1, payload_type: 0 }; const html = buildFieldTable(pkt, {}, [], {}); - assert.ok(!html.includes('section-path'), 'Should not render Path section for direct advert'); - assert.ok(html.includes('direct advert'), 'Should note direct advert in path_length description'); + assert.ok(!html.includes('section-path'), 'Should not render Path section with no hops'); + assert.ok(html.includes('hash_size=1 byte, hash_count=0'), 'a 0-hop flood still encodes its hash size'); }); } diff --git a/tests/unit/test-issue-1851-channel-message-scope.js b/tests/unit/test-issue-1851-channel-message-scope.js index 4352fa00a..1f4f8c8ca 100644 --- a/tests/unit/test-issue-1851-channel-message-scope.js +++ b/tests/unit/test-issue-1851-channel-message-scope.js @@ -7,6 +7,10 @@ * and client-side decryption of /api/packets rows), so each route must keep * the field, and the render must keep the '' state apart from null. * + * The same routes must carry path_hash_size, the path hash size (1-3 bytes) the + * sender used, which renders as "1-byte" / "2-bytes" / "3-bytes" right before + * the scope chip. + * * Loads the real public/channels.js in a vm sandbox and reads the HTML it * writes into #chMessages. */ @@ -30,6 +34,28 @@ function test(name, fn) { } catch (e) { done(e); } } +// channels.js calls the global pathHashSize() from app.js. app.js cannot share +// the channels sandbox (it would replace the api/registerPage stubs), so it +// runs in its own context and only the real helper is handed over. +function loadPathHashSize() { + const appCtx = { + window: { addEventListener() {}, dispatchEvent() {} }, + document: { + readyState: 'loading', addEventListener() {}, getElementById: () => null, + querySelector: () => null, querySelectorAll: () => [], createElement: () => ({}), head: { appendChild() {} }, + }, + console, addEventListener() {}, URLSearchParams, + localStorage: { getItem: () => null, setItem() {}, removeItem() {} }, + location: { hash: '' }, + setTimeout() {}, clearTimeout() {}, setInterval() {}, clearInterval() {}, + fetch: () => new Promise(() => {}), + }; + vm.createContext(appCtx); + vm.runInContext(fs.readFileSync('public/app.js', 'utf8'), appCtx); + return appCtx.pathHashSize; +} +const pathHashSize = loadPathHashSize(); + function makeChannelsSandbox(apiImpl) { const dom = {}; function makeEl(id) { @@ -95,6 +121,7 @@ function makeChannelsSandbox(apiImpl) { atob: (s) => Buffer.from(String(s), 'base64').toString('utf8'), crypto: { subtle: require('crypto').webcrypto.subtle }, TextEncoder, TextDecoder, Uint8Array, + pathHashSize, }; ctx.window.matchMedia = ctx.matchMedia; vm.createContext(ctx); @@ -314,7 +341,7 @@ test('a decrypt cache that already carries scope_name is still served by the del const { ctx } = makeChannelsSandbox(packetsApi(fx.packets)); ctx.ChannelDecrypt.setCache('#secret', fx.packets.map((p, i) => ({ sender: 'Old', text: 'cached ' + i, timestamp: p.first_seen, packetHash: p.hash, packetId: p.id, - hops: 0, snr: null, observers: [], scope_name: null, repeats: 1, + hops: 0, snr: null, observers: [], scope_name: null, path_hash_size: 0, repeats: 1, })), fx.lastTs, fx.packets.length); for (let i = 0; i < 10; i++) await Promise.resolve(); await ctx.window._channelsSelectChannelForTest('user:#secret', { @@ -324,6 +351,112 @@ test('a decrypt cache that already carries scope_name is still served by the del assert.deepStrictEqual(Array.from(state.messages, (m) => m.text), ['cached 0', 'cached 1', 'cached 2']); }); +console.log('\n=== channel message hash size ==='); + +// Matches the hash-size span and captures its label ("1-byte", "2-bytes", ...). +const HASH_SIZE = /]*>([^<]*)<\/span>/; + +test('REST messages render the hash size right before the scope chip', async () => { + const { ctx, dom } = makeChannelsSandbox(listApi((path) => { + if (path.indexOf('/channels/general/messages') === 0) { + return Promise.resolve({ messages: [ + { sender: 'Alice', text: 'two', timestamp: '2026-09-01T10:00:00Z', packetHash: 'h1', path_hash_size: 2, scope_name: '#belgium' }, + { sender: 'Bob', text: 'three', timestamp: '2026-09-01T10:01:00Z', packetHash: 'h2', path_hash_size: 3, scope_name: null }, + { sender: 'Carol', text: 'none', timestamp: '2026-09-01T10:02:00Z', packetHash: 'h3', path_hash_size: 0, scope_name: '#belgium' }, + { sender: 'Dave', text: 'older server', timestamp: '2026-09-01T10:03:00Z', packetHash: 'h4' }, + ] }); + } + return Promise.resolve({}); + })); + for (let i = 0; i < 10; i++) await Promise.resolve(); + await ctx.window._channelsSelectChannelForTest('general'); + const chunks = messageChunks(dom.chMessages.innerHTML); + assert.strictEqual(chunks.length, 4, 'expected 4 rendered messages'); + + assert.strictEqual((chunks[0].match(HASH_SIZE) || [])[1], '2-bytes'); + assert.ok(chunks[0].search(HASH_SIZE) < chunks[0].search(SCOPE_CHIP), 'hash size must come before the scope chip'); + assert.strictEqual((chunks[1].match(HASH_SIZE) || [])[1], '3-bytes', 'hash size renders without a scope chip too'); + assert.ok(!/ch-msg-hash-size/.test(chunks[2]), 'path_hash_size 0 (none encoded) must render nothing'); + assert.ok(!/ch-msg-hash-size/.test(chunks[3]), 'a message without path_hash_size must render nothing'); +}); + +test('path_hash_size is cast to a number before it is tested', async () => { + const { ctx, dom } = makeChannelsSandbox(listApi((path) => { + if (path.indexOf('/channels/general/messages') === 0) { + return Promise.resolve({ messages: [ + { sender: 'Alice', text: 'string zero', timestamp: '2026-09-01T10:00:00Z', packetHash: 'h1', path_hash_size: '0' }, + { sender: 'Bob', text: 'string two', timestamp: '2026-09-01T10:01:00Z', packetHash: 'h2', path_hash_size: '2' }, + ] }); + } + return Promise.resolve({}); + })); + for (let i = 0; i < 10; i++) await Promise.resolve(); + await ctx.window._channelsSelectChannelForTest('general'); + const chunks = messageChunks(dom.chMessages.innerHTML); + assert.ok(!/ch-msg-hash-size/.test(chunks[0]), '"0" must render nothing, not "0-bytes"'); + assert.strictEqual((chunks[1].match(HASH_SIZE) || [])[1], '2-bytes'); +}); + +test('WebSocket-appended message takes its hash size from raw_hex', () => { + const { ctx, dom } = makeChannelsSandbox(listApi(() => Promise.resolve({ messages: [] }))); + ctx.window._channelsSetStateForTest({ + selectedHash: 'general', + channels: [{ hash: 'general', name: 'general', messageCount: 0, lastActivityMs: 0 }], + messages: [], + }); + const packet = (hash, text, rawHex) => ({ + type: 'packet', + data: { + hash, raw_hex: rawHex, + decoded: { header: { payloadTypeName: 'GRP_TXT' }, payload: { channel: 'general', text } }, + packet: { hash, observer_name: 'Obs', raw_hex: rawHex }, + }, + }); + ctx.window._channelsProcessWSBatchForTest([ + packet('w1', 'Alice: 2-byte flood heard direct', '1540DEADBEEF'), + packet('w2', 'Bob: 3-byte transport flood', '141122334481AABBCCDEADBEEF'), + packet('w3', 'Carol: no raw_hex', undefined), + ], null); + const state = ctx.window._channelsGetStateForTest(); + assert.deepStrictEqual(Array.from(state.messages, (m) => m.path_hash_size), [2, 3, 0]); + const chunks = messageChunks(dom.chMessages.innerHTML); + assert.strictEqual((chunks[0].match(HASH_SIZE) || [])[1], '2-bytes'); + assert.strictEqual((chunks[1].match(HASH_SIZE) || [])[1], '3-bytes'); + assert.ok(!/ch-msg-hash-size/.test(chunks[2]), 'no raw_hex must render no hash size'); +}); + +test('client-side decrypted messages take their hash size from the /api/packets row', async () => { + const fx = encryptedChannelPackets('#secret'); + fx.packets[0].raw_hex = '1502AABBDEADBEEF'; + fx.packets[1].raw_hex = '1580DEADBEEF'; + const { ctx, dom } = makeChannelsSandbox(packetsApi(fx.packets)); + for (let i = 0; i < 10; i++) await Promise.resolve(); + await ctx.window._channelsSelectChannelForTest('user:#secret', { + userKey: fx.keyHex, channelHashByte: fx.channelHash, channelName: '#secret', + }); + const state = ctx.window._channelsGetStateForTest(); + assert.deepStrictEqual(Array.from(state.messages, (m) => m.path_hash_size), [1, 3, 0]); + const chunks = messageChunks(dom.chMessages.innerHTML); + assert.strictEqual((chunks[0].match(HASH_SIZE) || [])[1], '1-byte'); + assert.strictEqual((chunks[1].match(HASH_SIZE) || [])[1], '3-bytes'); +}); + +test('a decrypt cache written before path_hash_size existed is decrypted again', async () => { + const fx = encryptedChannelPackets('#secret'); + fx.packets.forEach((p) => { p.raw_hex = '1541AABBDEADBEEF'; }); + const { ctx } = makeChannelsSandbox(packetsApi(fx.packets)); + ctx.ChannelDecrypt.setCache('#secret', fx.packets.map((p, i) => ({ + sender: 'Old', text: 'cached ' + i, timestamp: p.first_seen, packetHash: p.hash, packetId: p.id, + hops: 0, snr: null, observers: [], scope_name: null, repeats: 1, + })), fx.lastTs, fx.packets.length); + for (let i = 0; i < 10; i++) await Promise.resolve(); + await ctx.window._channelsSelectChannelForTest('user:#secret', { + userKey: fx.keyHex, channelHashByte: fx.channelHash, channelName: '#secret', + }); + const state = ctx.window._channelsGetStateForTest(); + assert.deepStrictEqual(Array.from(state.messages, (m) => m.path_hash_size), [2, 2, 2]); +}); + Promise.all(pending).then(() => { console.log(`\n${passed} passed, ${failed} failed`); if (failed > 0) process.exit(1); diff --git a/tests/unit/test-packet-detail-hash-size.js b/tests/unit/test-packet-detail-hash-size.js new file mode 100644 index 000000000..a553c045e --- /dev/null +++ b/tests/unit/test-packet-detail-hash-size.js @@ -0,0 +1,169 @@ +/** + * The packet page reads a packet's path hash size with the same rule as the + * Channels view (pathHashSize in public/app.js), so the two pages one click + * apart cannot disagree. + * + * Firmware (meshcore-dev/MeshCore): + * src/Mesh.cpp sendFlood setPathHashSizeAndCount(size, 0) before the first + * hop, so a flood heard at 0 hops still carries it + * src/Mesh.cpp sendZeroHop path_len = 0 on a DIRECT route: no size at all + * + * Before this, packets.js hid the size whenever the hop count was 0, for every + * route type: a 0-hop flood channel message showed "2-bytes" on the Channels + * page and no Hash Size row on its packet page. + */ +'use strict'; +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +let passed = 0, failed = 0; +async function test(name, fn) { + try { await fn(); passed++; console.log(' ✅ ' + name); } + catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); } +} + +function makeSandbox() { + const registeredPages = {}; + const ctx = { + window: { + addEventListener: () => {}, removeEventListener: () => {}, dispatchEvent: () => {}, + innerWidth: 1200, PacketFilter: null, + }, + document: { + readyState: 'complete', + createElement: () => ({ id: '', textContent: '', innerHTML: '', className: '', style: {}, + appendChild: () => {}, setAttribute: () => {}, addEventListener: () => {}, + querySelectorAll: () => [], querySelector: () => null, + classList: { add: () => {}, remove: () => {}, contains: () => false } }), + head: { appendChild: () => {} }, getElementById: () => null, + addEventListener: () => {}, removeEventListener: () => {}, + querySelectorAll: () => [], querySelector: () => null, body: { appendChild: () => {} }, + }, + console, Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp, + Error, TypeError, RangeError, parseInt, parseFloat, isNaN, isFinite, + encodeURIComponent, decodeURIComponent, + setTimeout: () => {}, clearTimeout: () => {}, setInterval: () => {}, clearInterval: () => {}, + fetch: () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }), + performance: { now: () => Date.now() }, + localStorage: (() => { const s = {}; return { + getItem: k => s[k] || null, setItem: (k, v) => { s[k] = String(v); }, removeItem: k => { delete s[k]; }, + }; })(), + location: { hash: '' }, history: { replaceState: () => {} }, + CustomEvent: class CustomEvent {}, Map, Set, Promise, URLSearchParams, + addEventListener: () => {}, removeEventListener: () => {}, dispatchEvent: () => {}, + requestAnimationFrame: (cb) => setTimeout(cb, 0), + registerPage: (name, handler) => { registeredPages[name] = handler; }, + }; + vm.createContext(ctx); + return ctx; +} + +function loadInCtx(ctx, file) { + vm.runInContext(fs.readFileSync(file, 'utf8'), ctx, { filename: file }); + for (const k of Object.keys(ctx.window)) { ctx[k] = ctx.window[k]; } +} + +function loadPacketsSandbox() { + const ctx = makeSandbox(); + loadInCtx(ctx, 'public/payload-labels.js'); + loadInCtx(ctx, 'public/roles.js'); + loadInCtx(ctx, 'public/app.js'); + loadInCtx(ctx, 'public/packet-helpers.js'); + loadInCtx(ctx, 'public/hop-resolver.js'); + vm.runInContext(` + window.HopDisplay = { + renderHop: function(h) { return '' + h + ''; }, + _showFromBtn: function() {} + }; + `, ctx); + loadInCtx(ctx, 'public/packets.js'); + ctx.fetchAllNodes = () => Promise.resolve({ nodes: [] }); + ctx.api = (path) => Promise.resolve(path === '/observers' ? { observers: [] } : {}); + return ctx; +} + +const PAYLOAD = 'AA'.repeat(24); +// Header byte = (payload_type << 2) | route_type. GRP_TXT=5, ADVERT=4. +const GRP_TXT = { type: 'CHAN', channel: '#test', sender: 'Alice', text: 'Alice: hi' }; +const PACKETS = { + floodHeardDirect2: { raw_hex: '1540' + PAYLOAD, route_type: 1, payload_type: 5 }, + flood1Hop2: { raw_hex: '1541AABB' + PAYLOAD, route_type: 1, payload_type: 5, path_json: '["AABB"]' }, + directZeroHop: { raw_hex: '1600' + PAYLOAD, route_type: 2, payload_type: 5 }, + directNoHopsSizeBits: { raw_hex: '1640' + PAYLOAD, route_type: 2, payload_type: 5 }, +}; + +async function detailHashSizeRow(pkt) { + const ctx = loadPacketsSandbox(); + const panel = { innerHTML: '', querySelectorAll: () => [], querySelector: () => null, addEventListener: () => {} }; + await ctx._packetsTestAPI.renderDetail(panel, { + packet: Object.assign({ id: 1, hash: 'h1', decoded_json: JSON.stringify(GRP_TXT), path_json: '[]' }, pkt), + observations: [], + }); + assert.ok(panel.innerHTML.includes('detail-meta'), 'renderDetail did not render the meta list'); + const m = panel.innerHTML.match(/
Hash Size<\/dt>
([^<]*)<\/dd>/); + return m ? m[1] : null; +} + +function fieldTable(pkt, decoded) { + return loadPacketsSandbox()._packetsTestAPI.buildFieldTable(pkt, decoded || {}, [], []); +} + +(async () => { + console.log('\n=== packet detail: Hash Size row ==='); + + await test('0-hop flood: Hash Size row shows the size the sender set', async () => { + assert.strictEqual(await detailHashSizeRow(PACKETS.floodHeardDirect2), '2 bytes'); + }); + await test('relayed flood: Hash Size row unchanged', async () => { + assert.strictEqual(await detailHashSizeRow(PACKETS.flood1Hop2), '2 bytes'); + }); + await test('direct zero-hop: no Hash Size row', async () => { + assert.strictEqual(await detailHashSizeRow(PACKETS.directZeroHop), null); + }); + await test('direct with no hops but size bits set: no Hash Size row', async () => { + assert.strictEqual(await detailHashSizeRow(PACKETS.directNoHopsSizeBits), null); + }); + + console.log('\n=== packet detail: hex breakdown Path Length ==='); + + await test('0-hop flood: Path Length gives the size, not "direct advert"', () => { + const html = fieldTable(PACKETS.floodHeardDirect2); + assert.ok(html.includes('hash_size=2 bytes, hash_count=0'), 'got: ' + html); + assert.ok(!html.includes('direct advert'), 'a channel message is not an advert'); + }); + await test('direct zero-hop: Path Length says no size is encoded', () => { + const html = fieldTable(PACKETS.directZeroHop); + assert.ok(html.includes('hash_count=0 (no hash size encoded)'), 'got: ' + html); + assert.ok(!html.includes('hash_size='), 'no size to show'); + }); + await test('relayed flood: Path Length unchanged', () => { + assert.ok(fieldTable(PACKETS.flood1Hop2).includes('hash_size=2 bytes, hash_count=1')); + }); + + console.log('\n=== packet detail: Advertised Hash Size (ADVERT) ==='); + + const pubKey = 'C0DEDAD4'.padEnd(64, '0'); + const advert = (pathByte, routeType) => ({ + raw_hex: ((4 << 2) | routeType).toString(16).padStart(2, '0') + pathByte + pubKey + '00000000' + '0'.repeat(128), + route_type: routeType, payload_type: 4, + }); + const advertSize = (html) => { + const m = html.match(/Advertised Hash Size<\/td>]*>([^<]*) { + assert.strictEqual(advertSize(fieldTable(advert('80', 1), { type: 'ADVERT', pubKey })), '3 bytes'); + }); + await test('direct zero-hop advert: no Advertised Hash Size', () => { + assert.strictEqual(advertSize(fieldTable(advert('00', 2), { type: 'ADVERT', pubKey })), null); + }); + + console.log(''); + if (failed > 0) { + console.error(`❌ ${failed} test(s) failed, ${passed} passed`); + process.exit(1); + } + console.log(`✅ All ${passed} tests passed`); +})(); diff --git a/tests/unit/test-packets.js b/tests/unit/test-packets.js index 39072f5cf..e8b5aa621 100644 --- a/tests/unit/test-packets.js +++ b/tests/unit/test-packets.js @@ -833,12 +833,12 @@ console.log('\n=== packets.js: buildFieldTable ==='); }); test('buildFieldTable hash_size calculation', () => { - // Path byte 0xC0 → bits 7-6 = 3 → hash_size = 4, but hash_count = 0 - // Since #653: when hashCount == 0, shows "hash_count=0 (direct advert)" instead of hash_size - const pkt = { raw_hex: '00C0', route_type: 1, payload_type: 0 }; + // FLOOD (header 0x01), path byte 0xC0 → bits 7-6 = 3 → hash_size = 4, + // reserved (firmware rejects it), hash_count = 0: no size to show. + const pkt = { raw_hex: '01C0', route_type: 1, payload_type: 0 }; const decoded = {}; const result = api.buildFieldTable(pkt, decoded, [], []); - assert(result.includes('hash_count=0 (direct advert)')); + assert(result.includes('hash_count=0 (no hash size encoded)')); }); test('buildFieldTable hash_size shown when hash_count > 0', () => {