From fd7e55df8c71a79a3a73e8a4aea8ebfc10820412 Mon Sep 17 00:00:00 2001 From: dborup Date: Sun, 4 Oct 2026 11:00:49 +0200 Subject: [PATCH 1/2] Show sender path-hash width on channel messages --- cmd/server/channel_sender_hash_size_test.go | 80 ++++++++++++++++++++ cmd/server/db.go | 10 ++- cmd/server/store.go | 2 + docs/api-spec.md | 9 ++- internal/packetpath/hash_size_test.go | 30 ++++++++ internal/packetpath/path.go | 36 +++++++++ public/app.js | 14 ++++ public/channels.js | 50 ++++++++++-- public/packets.js | 12 +-- test-channels-observed-path-hash-size-e2e.js | 42 +++++++--- test-channels-observed-path-hash-size.js | 21 ++++- test-frontend-helpers.js | 31 +++++++- 12 files changed, 304 insertions(+), 33 deletions(-) create mode 100644 cmd/server/channel_sender_hash_size_test.go create mode 100644 internal/packetpath/hash_size_test.go diff --git a/cmd/server/channel_sender_hash_size_test.go b/cmd/server/channel_sender_hash_size_test.go new file mode 100644 index 000000000..7bf60cae5 --- /dev/null +++ b/cmd/server/channel_sender_hash_size_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "fmt" + "testing" + "time" +) + +// Both channel-message backends must describe the stored frame, including a +// flood heard before any relay has appended a hop. +func TestChannelMessagesSenderPathHashSize(t *testing.T) { + for _, makeDB := range []struct { + name string + open func(*testing.T) *DB + }{ + {"v3", func(t *testing.T) *DB { return setupTestDB(t) }}, + {"v2", setupTestDBV2}, + } { + t.Run(makeDB.name, func(t *testing.T) { + db := makeDB.open(t) + defer db.Close() + if _, err := db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES ('obs-hash', 'Observer', 'AAR')`); err != nil { + t.Fatal(err) + } + for i, tc := range []struct { + raw string + want int + }{ + {"1540DEADBEEF", 2}, // zero-hop flood + {"1580DEADBEEF", 3}, // zero-hop flood + {"1600DEADBEEF", 0}, // direct marker, no encoded width + } { + ts := time.Now().UTC().Add(time.Duration(i-5) * 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":"#sender-size","text":"Alice: message"}', '#sender-size')`, + tc.raw, fmt.Sprintf("sender-size-%d", i), ts.Format(time.RFC3339), 1) + if err != nil { + t.Fatal(err) + } + id, _ := res.LastInsertId() + if makeDB.name == "v3" { + _, err = db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp) VALUES (?, 1, '[]', ?)`, id, ts.Unix()) + } else { + _, err = db.conn.Exec(`INSERT INTO observations (transmission_id, observer_id, observer_name, path_json, timestamp) VALUES (?, 'obs-hash', 'Observer', '[]', ?)`, id, ts.Unix()) + } + if err != nil { + t.Fatal(err) + } + } + check := func(source string, messages []map[string]interface{}) { + t.Helper() + if len(messages) != 3 { + t.Fatalf("%s: got %d messages, want 3", source, len(messages)) + } + for _, message := range messages { + hash, _ := message["packetHash"].(string) + var n int + if _, err := fmt.Sscanf(hash, "sender-size-%d", &n); err != nil { + t.Fatalf("%s: unexpected hash %q", source, hash) + } + want := []int{2, 3, 0}[n] + if got := message["senderPathHashSize"]; got != want { + t.Errorf("%s %s: senderPathHashSize=%v, want %d", source, hash, got, want) + } + } + } + messages, _, err := db.GetChannelMessages("#sender-size", 100, 0) + if err != nil { + t.Fatal(err) + } + check("db", messages) + store := NewPacketStore(db, nil) + if err := store.Load(); err != nil { + t.Fatal(err) + } + messages, _ = store.GetChannelMessages("#sender-size", 100, 0) + check("store", messages) + }) + } +} diff --git a/cmd/server/db.go b/cmd/server/db.go index 72602cb83..1a064facd 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -17,6 +17,7 @@ import ( "github.com/meshcore-analyzer/dbschema" "github.com/meshcore-analyzer/geofilter" + "github.com/meshcore-analyzer/packetpath" regionutil "github.com/meshcore-analyzer/regions" _ "modernc.org/sqlite" ) @@ -3579,7 +3580,7 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . } 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, t.route_type` + scopeCol + resolvedPathCol + ` FROM observations o JOIN transmissions t ON t.id = o.transmission_id @@ -3587,7 +3588,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, t.route_type` + scopeCol + resolvedPathCol + ` FROM observations o JOIN transmissions t ON t.id = o.transmission_id @@ -3635,12 +3636,12 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . for rows.Next() { var pktID, txID int - var pktHash, dj, fs, obsID, obsName, pathJSON, resolvedPathJSON sql.NullString + var pktHash, dj, fs, rawHexHead, obsID, obsName, pathJSON, resolvedPathJSON sql.NullString var snr sql.NullFloat64 var obsTs sql.NullInt64 var routeType sql.NullInt64 var scopeName sql.NullString - scanArgs := []interface{}{&pktID, &txID, &pktHash, &dj, &fs, &obsID, &obsName, &snr, &pathJSON, &obsTs, &routeType} + scanArgs := []interface{}{&pktID, &txID, &pktHash, &dj, &fs, &rawHexHead, &obsID, &obsName, &snr, &pathJSON, &obsTs, &routeType} if db.hasScopeName() { scanArgs = append(scanArgs, &scopeName) } @@ -3754,6 +3755,7 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . "entryPrefix": entryPrefix, "entryObserverPubkey": entryObserverPubkey, "observedPathHashSizes": observedPathHashSizes(pathHashSizeMask), + "senderPathHashSize": packetpath.SenderHashSize(rawHexHead.String), }, Repeats: 1, PathHashSizeMask: pathHashSizeMask, diff --git a/cmd/server/store.go b/cmd/server/store.go index 566294d95..58ee15e01 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" ) @@ -6307,6 +6308,7 @@ func (s *PacketStore) GetChannelMessages(channelHash string, limit, offset int, "routeType": intPtrOrNil(tx.RouteType), "entryPrefix": pathFirstHop(tx.PathJSON), "observedPathHashSizes": tx.observedPathHashSizes(), + "senderPathHashSize": packetpath.SenderHashSize(tx.RawHex), }, Repeats: 1, Observers: observers, diff --git a/docs/api-spec.md b/docs/api-spec.md index e3e9a1030..84e5f8a73 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -1337,7 +1337,8 @@ Messages for a specific channel. "observers": [string], // observer names "hops": number, "snr": number | null, - "observedPathHashSizes": [number] // sorted unique relayed path widths (1–3) + "observedPathHashSizes": [number], // sorted unique relayed path widths (1–3) + "senderPathHashSize": number // 0 if unknown, otherwise header width (1–3) } ], "total": number // total deduplicated messages @@ -1351,6 +1352,12 @@ one value means different widths were observed for the same deduplicated message; the field describes those observations, not the sender's permanent configuration. +`senderPathHashSize` is read from the transmission's raw frame header, not +inferred from its observations. A flood can encode this width even when no +relay has forwarded it. A direct zero-hop marker, unsupported payload header, +or malformed frame yields 0 (unknown). This value describes that particular +frame, not the sender's permanent configuration. + --- ## Shared channel proposals diff --git a/internal/packetpath/hash_size_test.go b/internal/packetpath/hash_size_test.go new file mode 100644 index 000000000..6ab11f699 --- /dev/null +++ b/internal/packetpath/hash_size_test.go @@ -0,0 +1,30 @@ +package packetpath + +import "testing" + +func TestSenderHashSize(t *testing.T) { + for _, tc := range []struct { + name, raw string + want int + }{ + {"flood one byte", "1502AABBDEADBEEF", 1}, + {"flood two bytes at zero hops", "1540DEADBEEF", 2}, + {"flood three bytes at zero hops", "1580DEADBEEF", 3}, + {"transport flood", "141122334480DEADBEEF", 3}, + {"direct with path", "1641AABBDEADBEEF", 2}, + {"direct zero hop", "1600DEADBEEF", 0}, + {"transport direct zero hop", "171122334400DEADBEEF", 0}, + {"trace header is SNR", "2542AABBDEADBEEF", 0}, + {"reserved size", "15C0DEADBEEF", 0}, + {"missing path byte", "15", 0}, + {"truncated transport header", "141122", 0}, + {"bad hex", "ZZ40", 0}, + {"empty", "", 0}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := SenderHashSize(tc.raw); got != tc.want { + t.Errorf("SenderHashSize(%q) = %d, want %d", tc.raw, got, tc.want) + } + }) + } +} diff --git a/internal/packetpath/path.go b/internal/packetpath/path.go index 8ca0ef78f..1b2042456 100644 --- a/internal/packetpath/path.go +++ b/internal/packetpath/path.go @@ -52,6 +52,42 @@ func DecodePathFromRawHex(rawHex string) ([]string, error) { return hops, nil } +// SenderHashSize reads the width chosen for this frame from the path byte. +// Floods encode the width even with zero relay hops. A direct 0x00 path byte +// is sendZeroHop's marker and carries no width; TRACE path bytes are SNR. +// Zero means the width cannot be established from the supplied header. +func SenderHashSize(rawHex string) int { + header, ok := pathHeaderByte(rawHex, 0) + if !ok || !PathBytesAreHops(header>>2&0x0f) { + return 0 + } + route := RouteTypeFromHeader(header) + offset := 1 + if IsTransportRoute(route) { + offset += 4 + } + pathByte, ok := pathHeaderByte(rawHex, offset) + if !ok || pathByte>>6 == 3 { + return 0 + } + if pathByte == 0 && (route == RouteDirect || route == RouteTransportDirect) { + return 0 + } + return int(pathByte>>6) + 1 +} + +func pathHeaderByte(rawHex string, offset int) (byte, bool) { + start := offset * 2 + if len(rawHex) < start+2 { + return 0, false + } + var b [1]byte + if _, err := hex.Decode(b[:], []byte(rawHex[start:start+2])); 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/public/app.js b/public/app.js index 425e82e35..fee634f13 100644 --- a/public/app.js +++ b/public/app.js @@ -12,6 +12,20 @@ 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; } +/** Sender-selected path-hash width in this frame, or null when not encoded. */ +function senderPathHashSize(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; // TRACE path bytes are SNR + const route = header & 0x03; + const offset = getPathLenOffset(route) * 2; + const pathHex = rawHex.slice(offset, offset + 2); + if (!/^[0-9a-f]{2}$/i.test(pathHex)) return null; + const pathByte = parseInt(pathHex, 16); + if (pathByte === 0 && (route === 2 || route === 3)) return null; + const size = (pathByte >> 6) + 1; + return size <= 3 ? size : null; +} /** * scopeName is optional (callers that don't pass it get the original * unscoped "T" badge). Pass a packet's scope_name to also surface the diff --git a/public/channels.js b/public/channels.js index 10b9e2411..fe5b03702 100644 --- a/public/channels.js +++ b/public/channels.js @@ -44,9 +44,12 @@ function withObservedPathHashSizes(message, extraEvidence) { if (!message || typeof message !== 'object') return message; var sizes = unionObservedPathHashSizes(message, extraEvidence); - if (!sizes.length) return message; + var extraSenderSize = Number(extraEvidence && extraEvidence.senderPathHashSize); + var needsSenderSize = !message.senderPathHashSize && (extraSenderSize === 1 || extraSenderSize === 2 || extraSenderSize === 3); + if (!sizes.length && !needsSenderSize) return message; var copy = Object.assign({}, message); - copy.observedPathHashSizes = sizes; + if (sizes.length) copy.observedPathHashSizes = sizes; + if (needsSenderSize) copy.senderPathHashSize = extraSenderSize; return copy; } @@ -59,6 +62,18 @@ return '' + escapeHtml(label) + ''; } + // The header records the sender's choice even before a flood has relayed. + // Observation-path evidence is retained internally, but is not the label. + function renderSenderPathHashBadge(message) { + var size = Number(message && message.senderPathHashSize); + if (size !== 1 && size !== 2 && size !== 3) return ''; + return 'Sent with: ' + size + '-byte'; + } + + function senderSizeFromRawHex(rawHex) { + return typeof senderPathHashSize === 'function' ? senderPathHashSize(rawHex) : null; + } + // Client-decrypted messages are cached with their plaintext. A later API // response can carry new path evidence for the same transmission without // changing its candidate count or first_seen timestamp, so fold that @@ -71,6 +86,7 @@ } var evidenceByHash = new Map(); + var senderByHash = new Map(); for (var i = 0; i < candidates.length; i++) { var packet = candidates[i] && candidates[i].packet; var packetHash = packet && packet.hash; @@ -78,6 +94,8 @@ evidenceByHash.set( packetHash, unionObservedPathHashSizes(evidenceByHash.get(packetHash), packet)); + var size = senderSizeFromRawHex(packet.raw_hex); + if (size && !senderByHash.has(packetHash)) senderByHash.set(packetHash, size); } var merged = cachedMsgs; @@ -85,14 +103,19 @@ for (var j = 0; j < cachedMsgs.length; j++) { var cachedMessage = cachedMsgs[j]; var candidateEvidence = cachedMessage && evidenceByHash.get(cachedMessage.packetHash); - if (!candidateEvidence || !candidateEvidence.length) continue; + var candidateSenderSize = cachedMessage && senderByHash.get(cachedMessage.packetHash); + if ((!candidateEvidence || !candidateEvidence.length) && !candidateSenderSize) continue; var before = normalizeObservedPathHashSizes(cachedMessage); var after = unionObservedPathHashSizes(before, candidateEvidence); - if (before.length === after.length && before.every(function (size, index) { return size === after[index]; })) { + if (before.length === after.length && before.every(function (size, index) { return size === after[index]; }) && + (!candidateSenderSize || cachedMessage.senderPathHashSize)) { continue; } if (!changed) merged = cachedMsgs.slice(); - merged[j] = withObservedPathHashSizes(cachedMessage, candidateEvidence); + merged[j] = withObservedPathHashSizes(cachedMessage, { + observedPathHashSizes: candidateEvidence, + senderPathHashSize: candidateSenderSize, + }); changed = true; } return { messages: merged, changed: changed }; @@ -126,12 +149,16 @@ if (!Array.isArray(restMsgs)) return []; if (!Array.isArray(currentMsgs)) currentMsgs = []; var currentEvidenceByHash = new Map(); + var currentSenderByHash = new Map(); for (var c = 0; c < currentMsgs.length; c++) { var current = currentMsgs[c]; if (!current || !current.packetHash) continue; currentEvidenceByHash.set( current.packetHash, unionObservedPathHashSizes(currentEvidenceByHash.get(current.packetHash), current)); + if (current.senderPathHashSize && !currentSenderByHash.has(current.packetHash)) { + currentSenderByHash.set(current.packetHash, current.senderPathHashSize); + } } var restEvidenceByHash = new Map(); @@ -153,7 +180,10 @@ if (h) restHashes.add(h); mergedRest[i] = withObservedPathHashSizes( restMsgs[i], - h ? unionObservedPathHashSizes(currentEvidenceByHash.get(h), restEvidenceByHash.get(h)) : null); + h ? { + observedPathHashSizes: unionObservedPathHashSizes(currentEvidenceByHash.get(h), restEvidenceByHash.get(h)), + senderPathHashSize: currentSenderByHash.get(h), + } : null); } var now = Date.now(); var survivors = []; @@ -818,6 +848,7 @@ scope: c.packet.scope_name || null, routeType: c.packet.route_type ?? null, observedPathHashSizes: normalizeObservedPathHashSizes(c.packet), + senderPathHashSize: senderSizeFromRawHex(c.packet.raw_hex), repeats: 1, botReply: pingBotReply(text, d.path_len || 0, c.packet.snr || null, alreadyDecObserver) }); @@ -839,6 +870,7 @@ scope: c.packet.scope_name || null, routeType: c.packet.route_type ?? null, observedPathHashSizes: normalizeObservedPathHashSizes(c.packet), + senderPathHashSize: senderSizeFromRawHex(c.packet.raw_hex), repeats: 1, botReply: pingBotReply(result.message, 0, c.packet.snr || null, decObserver) }); @@ -1621,6 +1653,7 @@ m.data, m.data?.packet, payload); + var senderSize = senderSizeFromRawHex(m.data?.raw_hex ?? m.data?.packet?.raw_hex); // Same path[0]-resolved area as the REST message list (server-side // resolveEntryPointArea, see store.go) -- already computed at // broadcast time, just read it here. @@ -1655,6 +1688,7 @@ // Deduplicate by packet hash — same message seen by multiple observers var existing = pktHash ? messages.find(function (msg) { return msg.packetHash === pktHash; }) : null; if (existing) { + if (!existing.senderPathHashSize && senderSize) existing.senderPathHashSize = senderSize; existing.repeats = (existing.repeats || 1) + 1; if (observer && existing.observers && existing.observers.indexOf(observer) === -1) { existing.observers.push(observer); @@ -1682,6 +1716,7 @@ routeType: routeType, area: area, observedPathHashSizes: observedPathHashSizes, + senderPathHashSize: senderSize, botReply: pingBotReply(displayText, wsHops, snr, observer), // #1498: mark as WS-pushed so a later REST replacement // (selectChannel / refreshMessages) can merge instead of @@ -2530,7 +2565,7 @@ // (unique_prefix) to a positioned node; omitted otherwise, not // guessed. if (msg.area) meta.push(`area: ${escapeHtml(msg.area)}`); - const pathHashBadgeHtml = renderObservedPathHashBadge(msg); + const pathHashBadgeHtml = renderSenderPathHashBadge(msg); const safeId = btoa(encodeURIComponent(sender)); @@ -2598,6 +2633,7 @@ window._channelsNormalizeObservedPathHashSizesForTest = normalizeObservedPathHashSizes; window._channelsUnionObservedPathHashSizesForTest = unionObservedPathHashSizes; window._channelsRenderObservedPathHashBadgeForTest = renderObservedPathHashBadge; + window._channelsRenderSenderPathHashBadgeForTest = renderSenderPathHashBadge; window._channelsDeduplicateAndMergeForTest = deduplicateAndMerge; window._channelsLoadChannelsForTest = loadChannels; window._channelsRenderChannelRowForTest = renderChannelRow; diff --git a/public/packets.js b/public/packets.js index f501936ec..e35f2e0c4 100644 --- a/public/packets.js +++ b/public/packets.js @@ -3412,9 +3412,7 @@ } // 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); + const hashSize = senderPathHashSize(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); @@ -3769,7 +3767,11 @@ 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}`); + const encodedHashSize = senderPathHashSize(buf); + const pathDescription = encodedHashSize == null + ? `hash_count=${hashCountVal} (no encoded hash size)` + : `hash_size=${encodedHashSize} byte${encodedHashSize !== 1 ? 's' : ''}, hash_count=${hashCountVal}`; + rows += fieldRow(off, 'Path Length', '0x' + (buf.slice(off * 2, off * 2 + 2) || '??'), pathDescription); off += 1; // Path — render hops from path_json (what this observation reported). @@ -3801,7 +3803,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 != null) 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-channels-observed-path-hash-size-e2e.js b/test-channels-observed-path-hash-size-e2e.js index a60a6c404..4d867e061 100644 --- a/test-channels-observed-path-hash-size-e2e.js +++ b/test-channels-observed-path-hash-size-e2e.js @@ -9,7 +9,7 @@ const { chromium } = require('playwright'); const BASE = process.env.BASE_URL || 'http://localhost:13581'; -const TOOLTIP = 'Path hash size observed in one or more relayed wire paths for this message. Direct zero-hop copies do not provide hash-size evidence. This does not prove the sender’s permanent configuration.'; +const TOOLTIP = 'Path hash size encoded in the sender’s packet header'; let passed = 0; let failed = 0; @@ -65,28 +65,42 @@ function assert(condition, message) { repeats: 1, }; - await step('single known size renders the exact conservative badge and tooltip', async () => { - await render([{ ...baseMessage, observedPathHashSizes: [2] }]); + await step('sender size replaces the observed-path label', async () => { + await render([{ ...baseMessage, observedPathHashSizes: [1], senderPathHashSize: 2 }]); const badge = page.locator('.ch-path-hash-badge'); assert(await badge.count() === 1, 'expected one badge'); - assert(await badge.textContent() === 'Observed path hash: 2-byte', 'wrong single-size label'); + assert(await badge.textContent() === 'Sent with: 2-byte', 'wrong sender-size label'); assert(await badge.getAttribute('title') === TOOLTIP, 'tooltip wording drifted'); }); - await step('mixed evidence is sorted and unknown evidence has no badge', async () => { + await step('zero-hop flood has a sender label while direct zero-hop does not', async () => { + const sizes = await page.evaluate(() => [ + senderPathHashSize('1540DEADBEEF'), senderPathHashSize('1600DEADBEEF'), + ]); + assert(JSON.stringify(sizes) === '[2,null]', 'header rule disagrees with zero-hop contract'); + await render([ + { ...baseMessage, packetHash: 'flood-zero', senderPathHashSize: sizes[0] }, + { ...baseMessage, packetHash: 'direct-zero', senderPathHashSize: sizes[1] }, + ]); + assert(await page.locator('.ch-path-hash-badge').count() === 1, 'direct marker rendered a badge'); + assert(await page.locator('.ch-path-hash-badge').textContent() === 'Sent with: 2-byte', 'flood width hidden'); + }); + + await step('mixed observed evidence stays internal; unknown sender size has no badge', async () => { await render([ - { ...baseMessage, packetHash: 'mixed', observed_path_hash_sizes: [3, 1, 2] }, + { ...baseMessage, packetHash: 'mixed', observed_path_hash_sizes: [3, 1, 2], senderPathHashSize: 3 }, { ...baseMessage, packetHash: 'unknown', observedPathHashSizes: [] }, ]); const badges = page.locator('.ch-path-hash-badge'); assert(await badges.count() === 1, 'unknown message must not render a badge'); - assert(await badges.first().textContent() === 'Mixed path hashes: 1/2/3-byte', 'wrong mixed label'); + assert(await badges.first().textContent() === 'Sent with: 3-byte', 'wrong sender label'); }); await step('untrusted evidence cannot create markup or attributes', async () => { await render([{ ...baseMessage, observedPathHashSizes: [''], + senderPathHashSize: '', }]); assert(await page.locator('.ch-path-hash-badge').count() === 0, 'malformed evidence rendered a badge'); assert(await page.locator('#chMessages img').count() === 0, 'evidence injected an image'); @@ -100,7 +114,8 @@ function assert(condition, message) { type: 'packet', data: { hash: 'client-decrypted-hash', - packet: { observed_path_hash_sizes: [3] }, + raw_hex: '1580DEADBEEF', + packet: { observed_path_hash_sizes: [3], raw_hex: '1580DEADBEEF' }, decoded: { header: { payloadTypeName: 'GRP_TXT' }, payload: { @@ -117,7 +132,8 @@ function assert(condition, message) { const message = state.messages.find((item) => item.packetHash === 'client-decrypted-hash'); assert(message, 'client-decrypted WS message was not appended'); assert(JSON.stringify(message.observedPathHashSizes) === '[3]', 'WS evidence was not normalized'); - assert(await page.locator('.ch-path-hash-badge').textContent() === 'Observed path hash: 3-byte', 'WS badge missing'); + assert(message.senderPathHashSize === 3, 'WS sender size missing'); + assert(await page.locator('.ch-path-hash-badge').textContent() === 'Sent with: 3-byte', 'WS badge missing'); }); await step('duplicate WS observations union their known sizes', async () => { @@ -129,6 +145,7 @@ function assert(condition, message) { hash: 'ws-union-hash', observer: observer, observed_path_hash_sizes: [size], + raw_hex: '1540DEADBEEF', decoded: { payload: { channel: '#hash-evidence', sender: 'UnionNode', text: 'same' } }, }, }); @@ -140,7 +157,7 @@ function assert(condition, message) { return item && item.observedPathHashSizes; }); assert(JSON.stringify(sizes) === '[1,2]', 'duplicate observations did not union: ' + JSON.stringify(sizes)); - assert(await page.locator('.ch-path-hash-badge').textContent() === 'Mixed path hashes: 1/2-byte', 'mixed WS badge missing'); + assert(await page.locator('.ch-path-hash-badge').textContent() === 'Sent with: 2-byte', 'WS sender badge missing'); }); await step('delayed REST refresh cannot overwrite richer WS evidence', async () => { @@ -148,6 +165,7 @@ function assert(condition, message) { ...baseMessage, packetHash: 'refresh-union-hash', observedPathHashSizes: [2, 3], + senderPathHashSize: 2, _fromWS: true, _wsAt: Date.now(), }]); @@ -176,12 +194,12 @@ function assert(condition, message) { } }); assert(JSON.stringify(result) === '[1,2,3]', 'REST refresh lost WS evidence: ' + JSON.stringify(result)); - assert(await page.locator('.ch-path-hash-badge').textContent() === 'Mixed path hashes: 1/2/3-byte', 'refresh badge missing'); + assert(await page.locator('.ch-path-hash-badge').textContent() === 'Sent with: 2-byte', 'refresh badge missing'); }); await step('badge remains visible without horizontal overflow at 375px', async () => { await page.setViewportSize({ width: 375, height: 740 }); - await render([{ ...baseMessage, observedPathHashSizes: [1, 2, 3] }]); + await render([{ ...baseMessage, observedPathHashSizes: [1, 2, 3], senderPathHashSize: 2 }]); const metrics = await page.locator('.ch-path-hash-badge').evaluate((el) => { const rect = el.getBoundingClientRect(); const style = getComputedStyle(el); diff --git a/test-channels-observed-path-hash-size.js b/test-channels-observed-path-hash-size.js index 7d759ec71..9cdf3afa8 100644 --- a/test-channels-observed-path-hash-size.js +++ b/test-channels-observed-path-hash-size.js @@ -76,6 +76,7 @@ const ctx = { fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }), btoa: (s) => Buffer.from(s, 'binary').toString('base64'), registerPage: noop, + senderPathHashSize: (rawHex) => rawHex === '59C0' ? 3 : null, }; vm.createContext(ctx); // Expose the cache fetch helper only inside this VM so the regression can @@ -89,11 +90,12 @@ vm.runInContext(channelsSource, ctx); const normalize = ctx.window._channelsNormalizeObservedPathHashSizesForTest; const union = ctx.window._channelsUnionObservedPathHashSizesForTest; const badge = ctx.window._channelsRenderObservedPathHashBadgeForTest; +const senderBadge = ctx.window._channelsRenderSenderPathHashBadgeForTest; const merge = ctx.window._channelsMergeWsAppendedIntoRestForTest; const dedup = ctx.window._channelsDeduplicateAndMergeForTest; const fetchAndDecrypt = ctx.window._channelsFetchAndDecryptChannelForTest; -for (const [name, fn] of Object.entries({ normalize, union, badge, merge, dedup, fetchAndDecrypt })) { +for (const [name, fn] of Object.entries({ normalize, union, badge, senderBadge, merge, dedup, fetchAndDecrypt })) { if (typeof fn !== 'function') { console.error('FATAL: missing channels.js test export: ' + name); process.exit(2); @@ -155,6 +157,20 @@ test('unknown evidence renders no badge and cannot inject markup', () => { assert.strictEqual(badge({ observedPathHashSizes: [''] }), ''); }); +test('sender badge uses only a valid encoded width', () => { + assert.match(senderBadge({ senderPathHashSize: 2 }), />Sent with: 2-byte' }), ''); +}); + +test('REST refresh retains a sender width from the matching live message', () => { + const current = [{ packetHash: 'sender', senderPathHashSize: 3, _fromWS: true, _wsAt: Date.now() }]; + const rest = [{ packetHash: 'sender', text: 'REST' }]; + const out = merge(current, rest); + assert.strictEqual(out[0].senderPathHashSize, 3); + assert.strictEqual(rest[0].senderPathHashSize, undefined); +}); + test('REST refresh unions a matching WS message instead of erasing its evidence', () => { const current = [{ packetHash: 'same', @@ -211,6 +227,7 @@ test('delta cache absorbs later evidence for the same packet without re-decrypti packets: [{ hash: 'same-packet', first_seen: timestamp, + raw_hex: '59C0', observed_path_hash_sizes: [2], decoded_json: { type: 'CHAN', channel: '#test', sender: 'API sender', text: 'API text' }, }], @@ -220,9 +237,11 @@ test('delta cache absorbs later evidence for the same packet without re-decrypti assert.strictEqual(result.fromCache, true); assert.strictEqual(result.messages[0].text, 'Keep this decrypted text', 'cached plaintext is preserved'); assert.deepStrictEqual(plain(result.messages[0].observedPathHashSizes), [2]); + assert.strictEqual(result.messages[0].senderPathHashSize, 3); assert.strictEqual(decryptCalls, 0, 'same timestamp is not decrypted again'); assert.ok(stored, 'enriched legacy cache is persisted'); assert.deepStrictEqual(stored.messages[0].observedPathHashSizes, [2]); + assert.strictEqual(stored.messages[0].senderPathHashSize, 3); assert.strictEqual(stored.count, 1); }); diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index 9bddf5242..5d2c2c368 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -2238,6 +2238,16 @@ console.log('\n=== app.js: computeBreakdownRanges ==='); loadInCtx(ctx, 'public/app.js'); const computeBreakdownRanges = ctx.computeBreakdownRanges; + test('senderPathHashSize reads 0-hop flood width but not direct marker', () => { + assert.strictEqual(ctx.senderPathHashSize('1540DEADBEEF'), 2); + assert.strictEqual(ctx.senderPathHashSize('1580DEADBEEF'), 3); + assert.strictEqual(ctx.senderPathHashSize('1600DEADBEEF'), null); + assert.strictEqual(ctx.senderPathHashSize('141122334440DEADBEEF'), 2); + assert.strictEqual(ctx.senderPathHashSize('2540DEADBEEF'), null); + assert.strictEqual(ctx.senderPathHashSize('15C0DEADBEEF'), null); + assert.strictEqual(ctx.senderPathHashSize('15'), null); + }); + function findRange(ranges, label) { return ranges.find(r => r.label === label); } @@ -6134,12 +6144,23 @@ 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; + const hashHelperCtx = makeSandbox(); + loadInCtx(hashHelperCtx, 'public/roles.js'); + loadInCtx(hashHelperCtx, 'public/app.js'); + ftCtx.senderPathHashSize = hashHelperCtx.senderPathHashSize; loadInCtx(ftCtx, 'public/packets.js'); const { buildFieldTable, fieldRow } = ftCtx.window._packetsTestAPI; // Helper: build a hex string with specific bytes function makeHex(bytes) { return bytes.map(b => b.toString(16).padStart(2, '0')).join(''); } + test('packet hex breakdown shows the encoded width for a zero-hop flood', () => { + const html = buildFieldTable({ raw_hex: '1540DEADBEEF', route_type: 1, payload_type: 5 }, {}, [], {}); + assert.ok(html.includes('hash_size=2 bytes, hash_count=0'), 'flood width missing from breakdown'); + const direct = buildFieldTable({ raw_hex: '1600DEADBEEF', route_type: 2, payload_type: 5 }, {}, [], {}); + assert.ok(direct.includes('no encoded hash size'), 'direct marker must stay unknown'); + }); + test('FLOOD (route_type=1): path_length at byte 1, no transport codes', () => { // header=0x05 (route_type=1, payload=1), path_length=0x41 (hash_size=2, count=1), hop=AABB const raw = makeHex([0x05, 0x41, 0xAA, 0xBB]); @@ -6224,6 +6245,10 @@ 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; + const secondHashHelperCtx = makeSandbox(); + loadInCtx(secondHashHelperCtx, 'public/roles.js'); + loadInCtx(secondHashHelperCtx, 'public/app.js'); + ftCtx.senderPathHashSize = secondHashHelperCtx.senderPathHashSize; loadInCtx(ftCtx, 'public/packets.js'); const { buildFieldTable } = ftCtx.window._packetsTestAPI; @@ -6256,13 +6281,13 @@ 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', () => { + test('#844: zero-hop flood skips Path section but retains encoded width', () => { // path_len = 0x00 → hash_size=1, hash_count=0 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 without relay hops'); + assert.ok(html.includes('hash_size=1 byte, hash_count=0'), 'Should retain flood width in path_length description'); }); } From 0cdfc66c2d82d5b32b2664bb9c1197921d650b7a Mon Sep 17 00:00:00 2001 From: dborup Date: Mon, 5 Oct 2026 21:20:12 +0200 Subject: [PATCH 2/2] reviewfix(packets): read the detail hash size from the selected observation Addresses both P2 findings on PR #212, plus a red test gate neither review reported. 1. Frontend lint (no-undef). senderPathHashSize lives in app.js but was not declared in .eslintrc.json, so the three cross-file calls in channels.js and packets.js failed the mandatory no-undef gate. Declared as a read-only shared global alongside the other app.js helpers. 2. Selected-observation mismatch. renderDetail built the byte table from the selected observation (effectivePkt) but read the "Hash Size" summary from the original transmission's raw_hex. Observations of one transmission carry their own frames (routes.go fills observations[].raw_hex), so a selected direct zero-hop observation showed "Hash Size: 2 bytes" above a byte table that said it encodes no width. The summary now reads the same frame. 3. test-packets.js was left stale by the original change: the two buildFieldTable hash_size tests still expected the pre-PR "direct advert" wording and a 4-byte hash size, so `sh test-all.sh` -- a CI gate that runs before lint -- was red. Replaced with tests for the new contract, and restored the information the new wording had dropped: the byte breakdown now distinguishes TRACE SNR path bytes, an invalid 0b11 width field, and sendZeroHop's 0x00 direct marker instead of calling all three "no encoded hash size". Also replaced the hand-rolled senderPathHashSize stub in test-channels-observed-path-hash-size.js with the real app.js helper; the stub claimed a 3-byte width for '59C0', which the shipped rule reports as unknown. New browser regression test-packet-detail-sender-hash-size-obs-e2e.js switches between a zero-hop flood observation and a direct zero-hop one and asserts the summary and byte table agree; it fails on the pre-fix code. --- .eslintrc.json | 1 + .github/workflows/deploy.yml | 1 + public/packets.js | 30 ++- test-channels-observed-path-hash-size.js | 35 +++- ...-packet-detail-sender-hash-size-obs-e2e.js | 196 ++++++++++++++++++ test-packets.js | 77 +++++-- 6 files changed, 317 insertions(+), 23 deletions(-) create mode 100644 test-packet-detail-sender-hash-size-obs-e2e.js diff --git a/.eslintrc.json b/.eslintrc.json index ce7dfa49e..a43d8b3f3 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -270,6 +270,7 @@ "routeLayer": "readonly", "routeTypeName": "readonly", "scopeCellHtml": "readonly", + "senderPathHashSize": "readonly", "setupPullToReconnect": "readonly", "syncBadgeColors": "readonly", "timeAgo": "readonly", diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d3c46d0ac..5003b624d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -441,6 +441,7 @@ jobs: BASE_URL=http://localhost:13581 node test-slideover-1056-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1692-packets-init-parallel-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-147-packets-url-obs-e2e.js 2>&1 | tee -a e2e-output.txt + BASE_URL=http://localhost:13581 node test-packet-detail-sender-hash-size-obs-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-96-hide-control-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-180-packets-url-modal-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-slideover-1168-munger-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/public/packets.js b/public/packets.js index 27e73f346..f0c213132 100644 --- a/public/packets.js +++ b/public/packets.js @@ -3516,8 +3516,12 @@ } catch {} } - // Parse hash size from path byte - const hashSize = senderPathHashSize(pkt.raw_hex); + // Sender-selected hash width from the path byte. Read it from the SAME + // frame the rest of the panel describes (the selected observation's + // raw_hex, see buildFieldTable's argument below) -- observations of one + // transmission can carry different route types, so the original frame's + // header would otherwise contradict the byte breakdown. + const hashSize = senderPathHashSize(effectivePkt.raw_hex || 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); @@ -3870,12 +3874,26 @@ // Path length byte is at current offset (byte 1 for non-transport, byte 5 for transport) const pathLenOffset = off; const pathByte0 = parseInt(buf.slice(off * 2, off * 2 + 2), 16); - const hashSizeVal = isNaN(pathByte0) ? '?' : ((pathByte0 >> 6) + 1); const hashCountVal = isNaN(pathByte0) ? '?' : (pathByte0 & 0x3F); const encodedHashSize = senderPathHashSize(buf); - const pathDescription = encodedHashSize == null - ? `hash_count=${hashCountVal} (no encoded hash size)` - : `hash_size=${encodedHashSize} byte${encodedHashSize !== 1 ? 's' : ''}, hash_count=${hashCountVal}`; + // senderPathHashSize collapses every "no width here" case to null. This is + // the byte breakdown, so say WHICH one it is instead of dropping the bits + // on the floor: path bytes that are not hops at all (TRACE carries SNR), + // a width field of 0b11 (there is no 4-byte width -- the backend evidence + // model only knows 1/2/3, see observed_path_hash_sizes.go), or + // sendZeroHop's 0x00 direct marker, which encodes no width by design. + const headerByte = parseInt(buf.slice(0, 2), 16); + const pathBytesAreHops = !isNaN(headerByte) && ((headerByte >> 2) & 0x0F) !== 9; + let pathDescription; + if (encodedHashSize != null) { + pathDescription = `hash_size=${encodedHashSize} byte${encodedHashSize !== 1 ? 's' : ''}, hash_count=${hashCountVal}`; + } else if (!pathBytesAreHops) { + pathDescription = `hash_count=${hashCountVal} (TRACE: path bytes are SNR, not a hash width)`; + } else if (!isNaN(pathByte0) && (pathByte0 >> 6) === 3) { + pathDescription = `hash_count=${hashCountVal} (width bits 7-6 = 3: not a valid hash size, 1-3 bytes only)`; + } else { + pathDescription = `hash_count=${hashCountVal} (no encoded hash size)`; + } rows += fieldRow(off, 'Path Length', '0x' + (buf.slice(off * 2, off * 2 + 2) || '??'), pathDescription); off += 1; diff --git a/test-channels-observed-path-hash-size.js b/test-channels-observed-path-hash-size.js index adf46550c..e5c550e8e 100644 --- a/test-channels-observed-path-hash-size.js +++ b/test-channels-observed-path-hash-size.js @@ -13,6 +13,32 @@ const fs = require('fs'); const assert = require('assert'); const noop = () => {}; + +// Lift named top-level function declarations out of public/app.js and run them +// in their own context. app.js' load-time side effects (router, theme, WS) +// need a full DOM sandbox that this suite does not build, and the alternative +// -- stubbing the helper -- lets the test and the shipped rule diverge. +function loadAppHelper(wanted, names) { + const source = fs.readFileSync('public/app.js', 'utf8'); + let extracted = ''; + for (const name of names) { + const start = source.search(new RegExp('^function ' + name + '\\s*\\(', 'm')); + if (start < 0) throw new Error('public/app.js no longer declares function ' + name); + const open = source.indexOf('{', start); + let depth = 0, end = -1; + for (let i = open; i < source.length; i++) { + if (source[i] === '{') depth++; + else if (source[i] === '}' && --depth === 0) { end = i + 1; break; } + } + if (end < 0) throw new Error('unbalanced braces reading ' + name + ' from public/app.js'); + extracted += source.slice(start, end) + '\n'; + } + const helperCtx = { parseInt, RegExp, Number, String, Math }; + vm.createContext(helperCtx); + vm.runInContext(extracted + 'this.__helper = ' + wanted + ';', helperCtx); + if (typeof helperCtx.__helper !== 'function') throw new Error('failed to extract ' + wanted); + return helperCtx.__helper; +} const fakeEl = { addEventListener: noop, querySelector: () => fakeEl, @@ -76,7 +102,12 @@ const ctx = { fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }), btoa: (s) => Buffer.from(s, 'binary').toString('base64'), registerPage: noop, - senderPathHashSize: (rawHex) => rawHex === '59C0' ? 3 : null, + // The REAL app.js helper, not a hand-rolled stub: a stub silently drifts + // from the shipped header rule (an earlier revision of this file faked + // '59C0' -> 3, a width the real helper reports as unknown), so the merge + // assertions below would have passed against a contract nothing ships. + senderPathHashSize: loadAppHelper('senderPathHashSize', + ['isTransportRoute', 'getPathLenOffset', 'senderPathHashSize']), }; vm.createContext(ctx); // Expose the cache fetch helper only inside this VM so the regression can @@ -228,7 +259,7 @@ test('delta cache absorbs later evidence for the same packet without re-decrypti packets: [{ hash: 'same-packet', first_seen: timestamp, - raw_hex: '59C0', + raw_hex: '1580DEADBEEF', // flood header, path byte 0x80 -> 3-byte width observed_path_hash_sizes: [2], decoded_json: { type: 'CHAN', channel: '#test', sender: 'API sender', text: 'API text' }, }], diff --git a/test-packet-detail-sender-hash-size-obs-e2e.js b/test-packet-detail-sender-hash-size-obs-e2e.js new file mode 100644 index 000000000..2d4386063 --- /dev/null +++ b/test-packet-detail-sender-hash-size-obs-e2e.js @@ -0,0 +1,196 @@ +/** + * PR #212 review follow-up — the packet-detail "Hash Size" summary must + * describe the SAME frame as the raw-byte breakdown below it. + * + * Observations of one transmission carry their own wire bytes (routes.go + * fills observations[].raw_hex per observation, upstream #1999), so a frame + * whose header encodes a sender width can sit next to one that encodes none. + * renderDetail reads the byte table from the selected observation; before the + * fix the summary still read the original transmission's raw_hex, so a + * selected direct zero-hop observation showed "Hash Size: 2 bytes" above a + * byte table that said "no encoded hash size". + * + * The detail response is stubbed (context.route, so reloads and the + * observation-row click path both see it) with two observations that differ + * only in their frame header: + * obs 1 — 1540… flood, path byte 0x40 → 2-byte width, zero relay hops + * obs 2 — 1600… direct, path byte 0x00 → sendZeroHop marker, no width + * Nothing outside the local test server is contacted. + * + * Usage: BASE_URL=http://localhost:13581 node test-packet-detail-sender-hash-size-obs-e2e.js + */ +'use strict'; +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; +const HASH = 'e2e212aabbccdd'; +const PAYLOAD = 'aabbccddeeff00112233445566778899'; +const FLOOD_OBS = 912120001; // header 0x15 (flood), path byte 0x40 +const DIRECT_OBS = 912120002; // header 0x16 (direct), path byte 0x00 + +let passed = 0, failed = 0; +async function step(name, fn) { + try { await fn(); passed++; console.log(' ✓ ' + name); } + catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); } +} +function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } + +const DECODED = JSON.stringify({ + type: 'CHAN', channel: '#e2e-212', sender: 'HashSizeNode', text: 'sender hash-size regression', +}); + +function observation(id, rawHex, routeType, observerName, snr) { + return { + id: id, + raw_hex: rawHex, + timestamp: '2026-05-20T10:00:00Z', + observer_id: 'e2e-212-' + id, + observer_name: observerName, + direction: 'rx', + snr: snr, + rssi: -101, + score: null, + hash: HASH, + route_type: routeType, + payload_type: 5, + payload_version: 0, + path_json: '[]', + decoded_json: DECODED, + created_at: '2026-05-20T10:00:00Z', + }; +} + +async function stubDetail(context) { + await context.route((url) => url.pathname === '/api/packets/' + HASH, async (route) => { + const body = { + packet: { + id: 912120, + hash: HASH, + // The ORIGINAL transmission: a zero-hop flood that does encode a width. + raw_hex: '1540' + PAYLOAD, + route_type: 1, + payload_type: 5, + payload_version: 0, + first_seen: '2026-05-20T10:00:00Z', + timestamp: '2026-05-20T10:00:00Z', + observer_id: 'e2e-212-' + FLOOD_OBS, + snr: -5, + rssi: -101, + path_json: '[]', + decoded_json: DECODED, + scope_name: null, + }, + path: [], + observation_count: 2, + observations: [ + observation(FLOOD_OBS, '1540' + PAYLOAD, 1, 'e2e-212-flood', -5), + observation(DIRECT_OBS, '1600' + PAYLOAD, 2, 'e2e-212-direct', -7), + ], + }; + try { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); + } catch (_) { + try { await route.abort(); } catch (__) { /* context already closed */ } + } + }); +} + +// Summary width (null when the panel emits no Hash Size row), the byte +// table's Path Length description, and which observation is selected. +function readPanel(page) { + return page.evaluate(() => { + const dts = Array.from(document.querySelectorAll('dl.detail-meta dt')); + const dt = dts.find((d) => d.textContent.trim() === 'Hash Size'); + const dd = dt ? dt.nextElementSibling : null; + const rows = Array.from(document.querySelectorAll('table.field-table tr')); + const row = rows.find((r) => r.children[1] && r.children[1].textContent.trim() === 'Path Length'); + const current = document.querySelector('.detail-obs-row.observation-current'); + return { + summary: dd ? dd.textContent.trim() : null, + pathLen: row ? row.children[3].textContent.trim() : null, + obs: current ? current.dataset.obsId : null, + }; + }); +} + +// The contract: the two readings describe one frame, so they must agree. +function assertAgrees(state, where) { + assert(state.pathLen, where + ': byte table has no Path Length row'); + const encoded = state.pathLen.match(/hash_size=(\d+) byte/); + if (encoded) { + const want = encoded[1] + ' byte' + (encoded[1] === '1' ? '' : 's'); + assert(state.summary === want, + where + ': byte table says ' + state.pathLen + ' but summary says ' + JSON.stringify(state.summary)); + } else { + assert(/no encoded hash size/.test(state.pathLen), + where + ': unexpected Path Length description ' + state.pathLen); + assert(state.summary === null, + where + ': byte table encodes no width but summary says ' + JSON.stringify(state.summary)); + } +} + +(async () => { + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + console.log(`\n=== packet detail: sender hash size follows the selected observation (${BASE}) ===`); + + const context = await browser.newContext({ viewport: { width: 1400, height: 900 } }); + await stubDetail(context); + const page = await context.newPage(); + page.setDefaultTimeout(15000); + page.on('pageerror', (e) => console.error('[pageerror]', e.message)); + + async function open(obsId) { + const suffix = obsId ? '?obs=' + obsId : ''; + await page.goto(`${BASE}/#/packet/${HASH}${suffix}`, { waitUntil: 'load' }); + await page.reload({ waitUntil: 'load' }); + await page.waitForSelector('table.field-table', { timeout: 15000 }); + await page.waitForSelector('.detail-obs-row.observation-current', { timeout: 15000 }); + } + + await step('zero-hop flood observation: summary and byte table both report the 2-byte width', async () => { + await open(FLOOD_OBS); + const state = await readPanel(page); + assert(state.obs === String(FLOOD_OBS), 'wrong observation selected: ' + state.obs); + assert(state.summary === '2 bytes', 'flood summary width is ' + JSON.stringify(state.summary)); + assertAgrees(state, 'flood observation'); + }); + + await step('direct zero-hop observation: neither summary nor byte table claims a width', async () => { + await open(DIRECT_OBS); + const state = await readPanel(page); + assert(state.obs === String(DIRECT_OBS), 'wrong observation selected: ' + state.obs); + assert(/no encoded hash size/.test(state.pathLen), 'byte table lost the zero-hop marker'); + assert(state.summary === null, 'direct zero-hop still shows Hash Size: ' + state.summary); + assertAgrees(state, 'direct observation'); + }); + + await step('switching observations in place keeps the two readings in agreement', async () => { + await open(FLOOD_OBS); + assertAgrees(await readPanel(page), 'before switch'); + await page.click(`.detail-obs-row[data-obs-id="${DIRECT_OBS}"]`); + await page.waitForFunction((id) => { + const r = document.querySelector('.detail-obs-row.observation-current'); + return !!r && r.dataset.obsId === String(id); + }, DIRECT_OBS); + const afterDirect = await readPanel(page); + assertAgrees(afterDirect, 'after switching to the direct observation'); + assert(afterDirect.summary === null, 'summary kept the original flood width: ' + afterDirect.summary); + await page.click(`.detail-obs-row[data-obs-id="${FLOOD_OBS}"]`); + await page.waitForFunction((id) => { + const r = document.querySelector('.detail-obs-row.observation-current'); + return !!r && r.dataset.obsId === String(id); + }, FLOOD_OBS); + const backToFlood = await readPanel(page); + assertAgrees(backToFlood, 'after switching back to the flood observation'); + assert(backToFlood.summary === '2 bytes', 'flood width did not come back: ' + backToFlood.summary); + }); + + await context.close(); + await browser.close(); + console.log(`\n=== ${passed} passed, ${failed} failed ===`); + process.exit(failed ? 1 : 0); +})().catch((e) => { console.error(e); process.exit(1); }); diff --git a/test-packets.js b/test-packets.js index e117c58a0..0e3edbb62 100644 --- a/test-packets.js +++ b/test-packets.js @@ -1025,21 +1025,45 @@ console.log('\n=== packets.js: buildFieldTable ==='); assert(result.includes('cdcdcdcd'), 'should show the legacy field\'s truncated pubkey hex, got: ' + result); }); - 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 }; - const decoded = {}; - const result = api.buildFieldTable(pkt, decoded, [], []); - assert(result.includes('hash_count=0 (direct advert)')); - }); - - test('buildFieldTable hash_size shown when hash_count > 0', () => { - // Path byte 0xC1 → bits 7-6 = 3 → hash_size = 4, hash_count = 1 - const pkt = { raw_hex: '00C1aabbccdd', route_type: 1, payload_type: 0 }; - const decoded = {}; - const result = api.buildFieldTable(pkt, decoded, [], []); - assert(result.includes('hash_size=4')); + // PR #212 replaced the "direct advert" wording: the path byte encodes the + // sender's width even with zero relay hops, so a flood's width is reported + // and only the cases that genuinely carry no width stay unknown. The byte + // breakdown must still say WHICH case it is, not just "unknown". + test('buildFieldTable reports a zero-hop flood width instead of "direct advert"', () => { + // header 0x01 = route 1 (FLOOD), payload 0 (ADVERT); path byte 0x40 → + // bits 7-6 = 1 → hash_size 2, hash_count = 0. + const pkt = { raw_hex: '0140', route_type: 1, payload_type: 0 }; + const result = api.buildFieldTable(pkt, {}, [], []); + assert(result.includes('hash_size=2 bytes, hash_count=0'), + 'zero-hop flood must keep its encoded width, got: ' + result); + assert(!result.includes('direct advert'), 'stale "direct advert" wording resurfaced'); + }); + + test('buildFieldTable marks a 0b11 width field as invalid rather than hiding it', () => { + // Path byte 0xC1 → bits 7-6 = 3, which is no width at all: the evidence + // model (cmd/server/observed_path_hash_sizes.go) knows only 1/2/3 bytes. + const pkt = { raw_hex: '01C1aabbccdd', route_type: 1, payload_type: 0 }; + const result = api.buildFieldTable(pkt, {}, [], []); + assert(result.includes('hash_count=1'), 'hop count lost, got: ' + result); + assert(result.includes('width bits 7-6 = 3'), 'invalid width field not explained, got: ' + result); + assert(!result.includes('hash_size=4'), 'a 4-byte hash size must not be claimed'); + }); + + test('buildFieldTable keeps the direct zero-hop marker distinct from an invalid width', () => { + // header 0x02 = route 2 (DIRECT), path byte 0x00 = sendZeroHop's marker. + const pkt = { raw_hex: '0200', route_type: 2, payload_type: 0 }; + const result = api.buildFieldTable(pkt, {}, [], []); + assert(result.includes('hash_count=0 (no encoded hash size)'), + 'direct zero-hop marker description drifted, got: ' + result); + }); + + test('buildFieldTable does not read a hash width out of TRACE SNR bytes', () => { + // header 0x25 = payload 9 (TRACE), route 1. Its path bytes are SNR + // readings (internal/packetpath/route.go PathBytesAreHops), not hops. + const pkt = { raw_hex: '2541aabbccdd', route_type: 1, payload_type: 9 }; + const result = api.buildFieldTable(pkt, {}, [], []); + assert(result.includes('TRACE: path bytes are SNR'), 'TRACE path bytes mislabelled, got: ' + result); + assert(!result.includes('hash_size='), 'TRACE must not claim a hash size'); }); test('buildFieldTable handles empty raw_hex', () => { @@ -1619,6 +1643,29 @@ console.log('\n=== packets.js: scroll position preserved across renderTableRows }); } +// ===== packets.js: detail Hash Size source (PR #212 review) ===== +console.log('\n=== packets.js: detail Hash Size reads the selected observation ==='); +{ + const src = fs.readFileSync('public/packets.js', 'utf8'); + + // Behavioural coverage lives in + // test-packet-detail-sender-hash-size-obs-e2e.js, which only runs in the + // Playwright job. This is the fast guard: observations of one transmission + // carry their own frames, so the "Hash Size" summary and the byte table + // below it must read the SAME raw_hex, or the panel contradicts itself. + test('renderDetail derives Hash Size from the selected observation, not the original frame', () => { + assert.ok(src.includes('const hashSize = senderPathHashSize(effectivePkt.raw_hex || pkt.raw_hex);'), + 'the Hash Size summary must use the effective observation\'s frame'); + assert.ok(!/const hashSize = senderPathHashSize\(pkt\.raw_hex\)/.test(src), + 'reading the original transmission reintroduces the observation mismatch'); + }); + + test('the byte table is built from the same frame the summary reads', () => { + assert.ok(src.includes('buildFieldTable(effectivePkt.raw_hex ? effectivePkt : pkt,'), + 'buildFieldTable must receive the effective observation'); + }); +} + // ===== packets.js: View Path button (detail panel) ===== console.log('\n=== packets.js: View Path button ==='); {