From 95a6f79f74656a4b8ddf096ab53a027d77d8753c Mon Sep 17 00:00:00 2001 From: Sylvain Rabot Date: Mon, 28 Sep 2026 16:22:27 +0200 Subject: [PATCH 1/3] feat(channels): show the sender's path hash size on each message Each channel message now shows the path hash size its sender used ("1-byte", "2-bytes", "3-bytes"), right before the region scope chip. The size comes from the path byte of raw_hex. Mesh::sendFlood sets it before the first hop, so a flood packet carries it even at 0 hops; a direct packet with path byte 0x00 is sendZeroHop's marker and carries none, and TRACE path bytes are SNR readings. packetpath.HashSize (Go) and pathHashSize (app.js) implement that rule and share one test table. /api/channels/{hash}/messages returns hash_size from both the in-memory store and the SQLite fallback. Live WebSocket messages and channels decrypted in the browser compute it from raw_hex, which both already carry, so the broadcast payload is unchanged. Constraint: Hash size is set by the originator and repeaters keep it, so it is per message, not per observation Rejected: Add hash_size to the WS broadcast maps | every packet broadcast would grow for a value the client can read from raw_hex Rejected: Show the size whenever path byte bits 7-6 are 00 | a direct zero-hop packet would claim 1-byte Directive: packetpath.HashSize and pathHashSize must stay in agreement; their tests use the same cases Confidence: high Scope-risk: narrow Not-tested: Real 3-byte traffic in the browser (the fixture has only 1- and 2-byte messages; 3-byte is covered by unit tests) --- cmd/server/channel_message_hash_size_test.go | 105 ++++++++++++++++ cmd/server/db.go | 12 +- cmd/server/store.go | 2 + internal/packetpath/path.go | 44 +++++++ internal/packetpath/path_test.go | 30 +++++ public/app.js | 20 +++ public/channels.js | 18 ++- .../test-channel-live-decrypt-userprefix.js | 1 + tests/unit/test-frontend-helpers.js | 38 ++++++ .../test-issue-1851-channel-message-scope.js | 118 +++++++++++++++++- 10 files changed, 379 insertions(+), 9 deletions(-) create mode 100644 cmd/server/channel_message_hash_size_test.go 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..7cc60abf5 --- /dev/null +++ b/cmd/server/channel_message_hash_size_test.go @@ -0,0 +1,105 @@ +package main + +import ( + "encoding/json" + "testing" + "time" +) + +// The Channels view shows the path hash size (1-3 bytes) each message was +// sent with. /api/channels/{hash}/messages is served by the DB query when a DB +// is attached and by the in-memory store otherwise, so both 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() + db := setupTestDB(t) + 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() + if _, err := db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) + VALUES (?, 1, 9.5, -90, '[]', ?)`, 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["hash_size"] + if !present { + t.Errorf("%s: hash_size key missing", h) + continue + } + if got != want { + t.Errorf("%s: 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 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..b839332f8 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), + "hash_size": packetpath.HashSize(rawHexHead.String), }, Repeats: 1, } diff --git a/cmd/server/store.go b/cmd/server/store.go index df94d8c61..618a6134d 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), + "hash_size": packetpath.HashSize(tx.RawHex), }, Repeats: 1, Observers: observers, diff --git a/internal/packetpath/path.go b/internal/packetpath/path.go index 8ca0ef78f..baaa390c6 100644 --- a/internal/packetpath/path.go +++ b/internal/packetpath/path.go @@ -52,6 +52,50 @@ 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 a 0x00 path byte is Mesh::sendZeroHop's "zero hop" marker and +// says nothing about a size. 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 == 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..82660e1c4 100644 --- a/internal/packetpath/path_test.go +++ b/internal/packetpath/path_test.go @@ -148,3 +148,33 @@ func TestDecodeHopsForPayload_TraceReturnsError(t *testing.T) { t.Errorf("expected nil hops for TRACE, got %v", hops) } } + +func TestHashSize(t *testing.T) { + // Header byte: (payload_type << 2) | route_type. GRP_TXT=5, TRACE=9. + cases := []struct { + name string + raw string + want int + }{ + {"flood 1-byte, 2 hops", "1502ABCD" + "DEADBEEF", 1}, + {"flood 2-byte, 1 hop", "1541ABCD" + "DEADBEEF", 2}, + {"flood 3-byte, 0 hops (heard direct)", "1580" + "DEADBEEF", 3}, + {"flood 1-byte, 0 hops (heard direct)", "1500" + "DEADBEEF", 1}, + {"transport flood 2-byte, 0 hops", "14" + "11223344" + "40" + "DEADBEEF", 2}, + {"direct 2-byte, 1 hop", "1641ABCD" + "DEADBEEF", 2}, + {"direct zero-hop: size never encoded", "1600" + "DEADBEEF", 0}, + {"transport direct zero-hop", "17" + "11223344" + "00" + "DEADBEEF", 0}, + {"reserved size bits 0b11", "15C1ABCDEF01" + "DEADBEEF", 0}, + {"trace: path bytes are SNR", "2542ABCD" + "DEADBEEF", 0}, + {"lowercase hex", "1541abcd", 2}, + {"too short", "15", 0}, + {"transport too short", "141122", 0}, + {"invalid hex", "ZZ41", 0}, + {"empty", "", 0}, + } + for _, c := range 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..41b5782a1 100644 --- a/public/app.js +++ b/public/app.js @@ -12,6 +12,26 @@ 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 + * null when the packet carries none. Mirrors packetpath.HashSize on the server: + * a flood packet always encodes it (firmware sendFlood sets it before the first + * hop), a direct packet with path byte 0x00 is sendZeroHop's marker, and TRACE + * path bytes are SNR readings. + */ +function pathHashSize(rawHex) { + if (typeof rawHex !== 'string' || !/^[0-9a-f]{2}/i.test(rawHex)) return null; + const header = parseInt(rawHex.slice(0, 2), 16); + if (((header >> 2) & 0x0F) === 9) return null; + 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 null; + const pathByte = parseInt(pathHex, 16); + if (pathByte === 0 && (routeType === 2 || routeType === 3)) return null; + const size = (pathByte >> 6) + 1; + return size > 3 ? null : size; +} function transportBadge(rt) { return isTransportRoute(rt) ? ' T' : ''; } /** diff --git a/public/channels.js b/public/channels.js index 964bfa876..807af4f53 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 + // 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) || !('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, + 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, + 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, + 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,11 @@ 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}`); + // 0 (server) and null (client) both mean the packet encodes no size. + if (msg.hash_size) { + const hs = Number(msg.hash_size); + meta.push(`${hs}-byte${hs !== 1 ? 's' : ''}`); + } const scopeChip = messageScopeChipHtml(msg.scope_name); if (scopeChip) meta.push(scopeChip); 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..5b9f657a5 100644 --- a/tests/unit/test-frontend-helpers.js +++ b/tests/unit/test-frontend-helpers.js @@ -236,6 +236,38 @@ 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'); + + // Same cases as TestHashSize in internal/packetpath/path_test.go: the two + // implementations must agree. Header byte = (payload_type << 2) | route_type. + const cases = [ + ['flood 1-byte, 2 hops', '1502ABCDDEADBEEF', 1], + ['flood 2-byte, 1 hop', '1541ABCDDEADBEEF', 2], + ['flood 3-byte, 0 hops (heard direct)', '1580DEADBEEF', 3], + ['flood 1-byte, 0 hops (heard direct)', '1500DEADBEEF', 1], + ['transport flood 2-byte, 0 hops', '141122334440DEADBEEF', 2], + ['direct 2-byte, 1 hop', '1641ABCDDEADBEEF', 2], + ['direct zero-hop: size never encoded', '1600DEADBEEF', null], + ['transport direct zero-hop', '171122334400DEADBEEF', null], + ['reserved size bits 0b11', '15C1ABCDEF01DEADBEEF', null], + ['trace: path bytes are SNR', '2542ABCDDEADBEEF', null], + ['lowercase hex', '1541abcd', 2], + ['too short', '15', null], + ['transport too short', '141122', null], + ['invalid hex', 'ZZ41', null], + ['empty', '', null], + ['null', null, null], + ['undefined', undefined, null], + ]; + for (const [name, raw, want] of cases) { + test('pathHashSize: ' + name, () => assert.strictEqual(ctx.pathHashSize(raw), want)); + } +} + console.log('\n=== app.js: scopeCellHtml ==='); { const ctx = makeSandbox(); @@ -2654,6 +2686,12 @@ 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; + // Real app.js helper, loaded in its own sandbox so app.js does not + // replace the api/registerPage stubs above. + const appCtx = makeSandbox(); + loadInCtx(appCtx, 'public/roles.js'); + loadInCtx(appCtx, 'public/app.js'); + ctx.pathHashSize = appCtx.pathHashSize; loadInCtx(ctx, 'public/channel-decrypt.js'); loadInCtx(ctx, 'public/channels.js'); ctx._pageHandlers.init(appEl); diff --git a/tests/unit/test-issue-1851-channel-message-scope.js b/tests/unit/test-issue-1851-channel-message-scope.js index 4352fa00a..e99a40112 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 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, hash_size: null, 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,95 @@ 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', hash_size: 2, scope_name: '#belgium' }, + { sender: 'Bob', text: 'three', timestamp: '2026-09-01T10:01:00Z', packetHash: 'h2', hash_size: 3, scope_name: null }, + { sender: 'Carol', text: 'none', timestamp: '2026-09-01T10:02:00Z', packetHash: 'h3', 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]), 'hash_size 0 (none encoded) must render nothing'); + assert.ok(!/ch-msg-hash-size/.test(chunks[3]), 'a message without hash_size must render nothing'); +}); + +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.hash_size), [2, 3, null]); + 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.hash_size), [1, 3, null]); + 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 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.hash_size), [2, 2, 2]); +}); + Promise.all(pending).then(() => { console.log(`\n${passed} passed, ${failed} failed`); if (failed > 0) process.exit(1); From d16f89299277ca5c6fbf2a1cf4743ad1f3e4e8d8 Mon Sep 17 00:00:00 2001 From: Sylvain Rabot Date: Mon, 28 Sep 2026 17:48:15 +0200 Subject: [PATCH 2/3] fix(lint): declare the pathHashSize global for eslint no-undef channels.js calls pathHashSize() from app.js; like every other app.js global it has to be listed in .eslintrc.json, or the frontend lint step fails with no-undef. Confidence: high Scope-risk: narrow --- .eslintrc.json | 1 + 1 file changed, 1 insertion(+) 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", From 16bf6c420068c2dee7371bf19131c13346c5a546 Mon Sep 17 00:00:00 2001 From: Sylvain Rabot Date: Sun, 4 Oct 2026 13:44:23 +0200 Subject: [PATCH 3/3] fix(packets): one path hash size rule for the Channels and packet pages Review feedback on #2089. packets.js hid the hash size whenever the hop count was 0, for every route type, so a 0-hop flood channel message showed "2-bytes" on the Channels page and no Hash Size row on its packet page one click away. The detail row, the hex breakdown's Path Length description (which called every such packet a "direct advert") and Advertised Hash Size now all call pathHashSize. packetpath.HashSize and pathHashSize now treat a direct packet with no hops as carrying no size whatever its size bits say, the rule cmd/server/decoder.go already applies to path.hashSize; the fixture has real direct packets with path byte 0x40. Both helpers return 0 for unknown and run one shared case file, and a cmd/server test checks the helper against the decoder, so the shared cases run in CI. The channel message field is renamed path_hash_size: nodes already use hash_size for the size a node is observed to use. The render casts before testing, and the pre-v3 SQLite query is tested. Constraint: Field is unreleased, so the rename breaks no consumer Rejected: Keep pathByte == 0 as the direct zero-hop test | disagrees with decoder.go for 0x40-style direct packets present in real data Rejected: Rewrite the other path-byte parsers (hop-filter.js, decoders, decrypt CLI) | reviewer scoped the ask to the frontend source and packets.js Directive: Change the size rule in test-fixtures/path-hash-size-cases.json first; Go, JS and the decoder agreement test all read it Confidence: high Scope-risk: narrow Not-tested: internal/packetpath tests themselves still have no CI job; covered via cmd/server/path_hash_size_agreement_test.go --- cmd/server/channel_message_hash_size_test.go | 42 ++++- cmd/server/db.go | 2 +- cmd/server/path_hash_size_agreement_test.go | 62 +++++++ cmd/server/store.go | 2 +- internal/packetpath/path.go | 10 +- internal/packetpath/path_test.go | 49 +++-- public/app.js | 21 ++- public/channels.js | 20 +-- public/packets.js | 18 +- test-all.sh | 1 + test-fixtures/path-hash-size-cases.json | 22 +++ tests/unit/test-frontend-helpers.js | 64 ++++--- .../test-issue-1851-channel-message-scope.js | 39 ++-- tests/unit/test-packet-detail-hash-size.js | 169 ++++++++++++++++++ tests/unit/test-packets.js | 8 +- 15 files changed, 416 insertions(+), 113 deletions(-) create mode 100644 cmd/server/path_hash_size_agreement_test.go create mode 100644 test-fixtures/path-hash-size-cases.json create mode 100644 tests/unit/test-packet-detail-hash-size.js diff --git a/cmd/server/channel_message_hash_size_test.go b/cmd/server/channel_message_hash_size_test.go index 7cc60abf5..6a53a345a 100644 --- a/cmd/server/channel_message_hash_size_test.go +++ b/cmd/server/channel_message_hash_size_test.go @@ -7,8 +7,10 @@ import ( ) // The Channels view shows the path hash size (1-3 bytes) each message was -// sent with. /api/channels/{hash}/messages is served by the DB query when a DB -// is attached and by the in-memory store otherwise, so both must carry it. +// 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{ @@ -20,7 +22,13 @@ var chHashSizeWant = map[string]float64{ func setupChannelHashSizeDB(t *testing.T) *DB { t.Helper() - db := setupTestDB(t) + 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) } @@ -43,8 +51,13 @@ func setupChannelHashSizeDB(t *testing.T) *DB { t.Fatalf("insert tx %s: %v", r.hash, err) } txID, _ := res.LastInsertId() - if _, err := db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) - VALUES (?, 1, 9.5, -90, '[]', ?)`, txID, ts.Unix()); err != nil { + 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) } } @@ -72,13 +85,13 @@ func assertChannelMessageHashSizes(t *testing.T, messages []map[string]interface t.Errorf("unexpected packetHash %q", h) continue } - got, present := decoded["hash_size"] + got, present := decoded["path_hash_size"] if !present { - t.Errorf("%s: hash_size key missing", h) + t.Errorf("%s: path_hash_size key missing", h) continue } if got != want { - t.Errorf("%s: hash_size = %#v, want %v", h, got, want) + t.Errorf("%s: path_hash_size = %#v, want %v", h, got, want) } } } @@ -93,6 +106,19 @@ func TestDBGetChannelMessagesCarriesHashSize(t *testing.T) { 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() diff --git a/cmd/server/db.go b/cmd/server/db.go index b839332f8..d1d24e28c 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -2315,7 +2315,7 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . "hops": hops, "snr": nullFloat(snr), "scope_name": nullStr(scopeName), - "hash_size": packetpath.HashSize(rawHexHead.String), + "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 618a6134d..4f7fa4a37 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -5760,7 +5760,7 @@ func (s *PacketStore) GetChannelMessages(channelHash string, limit, offset int, "hops": hops, "snr": snrVal, "scope_name": strPtrOrNil(tx.ScopeName), - "hash_size": packetpath.HashSize(tx.RawHex), + "path_hash_size": packetpath.HashSize(tx.RawHex), }, Repeats: 1, Observers: observers, diff --git a/internal/packetpath/path.go b/internal/packetpath/path.go index baaa390c6..7b990c71c 100644 --- a/internal/packetpath/path.go +++ b/internal/packetpath/path.go @@ -57,9 +57,11 @@ func DecodePathFromRawHex(rawHex string) ([]string, error) { // // 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 a 0x00 path byte is Mesh::sendZeroHop's "zero hop" marker and -// says nothing about a size. TRACE path bytes are SNR readings, not hashes. -// Only the first few bytes are parsed, so this is cheap on hot paths. +// 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) { @@ -74,7 +76,7 @@ func HashSize(rawHex string) int { if !ok { return 0 } - if pathByte == 0 && (routeType == RouteDirect || routeType == RouteTransportDirect) { + if pathByte&0x3F == 0 && (routeType == RouteDirect || routeType == RouteTransportDirect) { return 0 } size := int(pathByte>>6) + 1 diff --git a/internal/packetpath/path_test.go b/internal/packetpath/path_test.go index 82660e1c4..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" ) @@ -149,32 +150,30 @@ func TestDecodeHopsForPayload_TraceReturnsError(t *testing.T) { } } +// 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) { - // Header byte: (payload_type << 2) | route_type. GRP_TXT=5, TRACE=9. - cases := []struct { - name string - raw string - want int - }{ - {"flood 1-byte, 2 hops", "1502ABCD" + "DEADBEEF", 1}, - {"flood 2-byte, 1 hop", "1541ABCD" + "DEADBEEF", 2}, - {"flood 3-byte, 0 hops (heard direct)", "1580" + "DEADBEEF", 3}, - {"flood 1-byte, 0 hops (heard direct)", "1500" + "DEADBEEF", 1}, - {"transport flood 2-byte, 0 hops", "14" + "11223344" + "40" + "DEADBEEF", 2}, - {"direct 2-byte, 1 hop", "1641ABCD" + "DEADBEEF", 2}, - {"direct zero-hop: size never encoded", "1600" + "DEADBEEF", 0}, - {"transport direct zero-hop", "17" + "11223344" + "00" + "DEADBEEF", 0}, - {"reserved size bits 0b11", "15C1ABCDEF01" + "DEADBEEF", 0}, - {"trace: path bytes are SNR", "2542ABCD" + "DEADBEEF", 0}, - {"lowercase hex", "1541abcd", 2}, - {"too short", "15", 0}, - {"transport too short", "141122", 0}, - {"invalid hex", "ZZ41", 0}, - {"empty", "", 0}, - } - for _, c := range cases { - if got := HashSize(c.raw); got != c.want { - t.Errorf("%s: HashSize(%q) = %d, want %d", c.name, c.raw, got, c.want) + 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 41b5782a1..1e0801c0d 100644 --- a/public/app.js +++ b/public/app.js @@ -14,23 +14,26 @@ function isTransportRoute(rt) { return rt === 0 || rt === 3; } function getPathLenOffset(routeType) { return isTransportRoute(routeType) ? 5 : 1; } /** * Path hash size (1-3 bytes) the originator chose, from raw_hex's path byte, or - * null when the packet carries none. Mirrors packetpath.HashSize on the server: - * a flood packet always encodes it (firmware sendFlood sets it before the first - * hop), a direct packet with path byte 0x00 is sendZeroHop's marker, and TRACE - * path bytes are SNR readings. + * 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) { - if (typeof rawHex !== 'string' || !/^[0-9a-f]{2}/i.test(rawHex)) return null; + // 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) === 9) return null; + 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 null; + if (!/^[0-9a-f]{2}$/i.test(pathHex)) return 0; const pathByte = parseInt(pathHex, 16); - if (pathByte === 0 && (routeType === 2 || routeType === 3)) return null; + if ((pathByte & 0x3F) === 0 && (routeType === ROUTE_DIRECT || routeType === ROUTE_TRANSPORT_DIRECT)) return 0; const size = (pathByte >> 6) + 1; - return size > 3 ? null : size; + return size > 3 ? 0 : size; } function transportBadge(rt) { return isTransportRoute(rt) ? ' T' : ''; } diff --git a/public/channels.js b/public/channels.js index 807af4f53..0d7c0cc38 100644 --- a/public/channels.js +++ b/public/channels.js @@ -594,9 +594,9 @@ // M5: Cache invalidation — if total candidate count changed, re-decrypt everything var totalCandidates = candidates.length; // #1851: a cache written before messages carried scope_name (or - // 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) || !('hash_size' in m); }); + // 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 @@ -682,7 +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, - hash_size: pathHashSize(c.packet.raw_hex), + path_hash_size: pathHashSize(c.packet.raw_hex), repeats: 1 }); continue; @@ -700,7 +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, - hash_size: pathHashSize(c.packet.raw_hex), + path_hash_size: pathHashSize(c.packet.raw_hex), repeats: 1 }); } else { @@ -1494,7 +1494,7 @@ hops: payload.path_len || 0, snr: snr, scope_name: scopeName, - hash_size: hashSize, + 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 @@ -2285,11 +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}`); - // 0 (server) and null (client) both mean the packet encodes no size. - if (msg.hash_size) { - const hs = Number(msg.hash_size); - meta.push(`${hs}-byte${hs !== 1 ? 's' : ''}`); - } + // 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-frontend-helpers.js b/tests/unit/test-frontend-helpers.js index 5b9f657a5..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 ==='); { @@ -242,30 +255,17 @@ console.log('\n=== app.js: pathHashSize ==='); loadInCtx(ctx, 'public/roles.js'); loadInCtx(ctx, 'public/app.js'); - // Same cases as TestHashSize in internal/packetpath/path_test.go: the two - // implementations must agree. Header byte = (payload_type << 2) | route_type. - const cases = [ - ['flood 1-byte, 2 hops', '1502ABCDDEADBEEF', 1], - ['flood 2-byte, 1 hop', '1541ABCDDEADBEEF', 2], - ['flood 3-byte, 0 hops (heard direct)', '1580DEADBEEF', 3], - ['flood 1-byte, 0 hops (heard direct)', '1500DEADBEEF', 1], - ['transport flood 2-byte, 0 hops', '141122334440DEADBEEF', 2], - ['direct 2-byte, 1 hop', '1641ABCDDEADBEEF', 2], - ['direct zero-hop: size never encoded', '1600DEADBEEF', null], - ['transport direct zero-hop', '171122334400DEADBEEF', null], - ['reserved size bits 0b11', '15C1ABCDEF01DEADBEEF', null], - ['trace: path bytes are SNR', '2542ABCDDEADBEEF', null], - ['lowercase hex', '1541abcd', 2], - ['too short', '15', null], - ['transport too short', '141122', null], - ['invalid hex', 'ZZ41', null], - ['empty', '', null], - ['null', null, null], - ['undefined', undefined, null], - ]; - for (const [name, raw, want] of cases) { - test('pathHashSize: ' + name, () => assert.strictEqual(ctx.pathHashSize(raw), want)); + // 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 ==='); @@ -2686,12 +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; - // Real app.js helper, loaded in its own sandbox so app.js does not - // replace the api/registerPage stubs above. - const appCtx = makeSandbox(); - loadInCtx(appCtx, 'public/roles.js'); - loadInCtx(appCtx, 'public/app.js'); - ctx.pathHashSize = appCtx.pathHashSize; + ctx.pathHashSize = realPathHashSize(); loadInCtx(ctx, 'public/channel-decrypt.js'); loadInCtx(ctx, 'public/channels.js'); ctx._pageHandlers.init(appEl); @@ -5687,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; @@ -5777,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; @@ -5809,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 e99a40112..1f4f8c8ca 100644 --- a/tests/unit/test-issue-1851-channel-message-scope.js +++ b/tests/unit/test-issue-1851-channel-message-scope.js @@ -7,7 +7,7 @@ * 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 hash_size, the path hash size (1-3 bytes) the + * 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. * @@ -341,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, hash_size: 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', { @@ -360,9 +360,9 @@ 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', hash_size: 2, scope_name: '#belgium' }, - { sender: 'Bob', text: 'three', timestamp: '2026-09-01T10:01:00Z', packetHash: 'h2', hash_size: 3, scope_name: null }, - { sender: 'Carol', text: 'none', timestamp: '2026-09-01T10:02:00Z', packetHash: 'h3', hash_size: 0, scope_name: '#belgium' }, + { 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' }, ] }); } @@ -376,8 +376,25 @@ test('REST messages render the hash size right before the scope chip', async () 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]), 'hash_size 0 (none encoded) must render nothing'); - assert.ok(!/ch-msg-hash-size/.test(chunks[3]), 'a message without hash_size must render nothing'); + 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', () => { @@ -401,7 +418,7 @@ test('WebSocket-appended message takes its hash size from raw_hex', () => { packet('w3', 'Carol: no raw_hex', undefined), ], null); const state = ctx.window._channelsGetStateForTest(); - assert.deepStrictEqual(Array.from(state.messages, (m) => m.hash_size), [2, 3, null]); + 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'); @@ -418,13 +435,13 @@ test('client-side decrypted messages take their hash size from the /api/packets userKey: fx.keyHex, channelHashByte: fx.channelHash, channelName: '#secret', }); const state = ctx.window._channelsGetStateForTest(); - assert.deepStrictEqual(Array.from(state.messages, (m) => m.hash_size), [1, 3, null]); + 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 hash_size existed is decrypted again', async () => { +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)); @@ -437,7 +454,7 @@ test('a decrypt cache written before hash_size existed is decrypted again', asyn userKey: fx.keyHex, channelHashByte: fx.channelHash, channelName: '#secret', }); const state = ctx.window._channelsGetStateForTest(); - assert.deepStrictEqual(Array.from(state.messages, (m) => m.hash_size), [2, 2, 2]); + assert.deepStrictEqual(Array.from(state.messages, (m) => m.path_hash_size), [2, 2, 2]); }); Promise.all(pending).then(() => { 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', () => {