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/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 2e4a945b8..49de8de12 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"
)
@@ -6391,6 +6392,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 619e731ab..d79de6c2b 100644
--- a/docs/api-spec.md
+++ b/docs/api-spec.md
@@ -1408,7 +1408,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
@@ -1422,6 +1423,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 95f5ffa47..9d402753e 100644
--- a/public/channels.js
+++ b/public/channels.js
@@ -48,9 +48,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;
}
@@ -63,6 +66,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
@@ -75,6 +90,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;
@@ -82,6 +98,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;
@@ -89,14 +107,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 };
@@ -130,12 +153,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();
@@ -157,7 +184,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 = [];
@@ -978,6 +1008,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)
});
@@ -999,6 +1030,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)
});
@@ -1769,6 +1801,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.
@@ -1805,6 +1838,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);
@@ -1832,6 +1866,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
@@ -2747,7 +2782,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));
@@ -2816,6 +2851,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 826c3cd65..f0c213132 100644
--- a/public/packets.js
+++ b/public/packets.js
@@ -3516,10 +3516,12 @@
} 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);
+ // 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);
@@ -3872,9 +3874,27 @@
// 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);
- 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);
+ // 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;
// Path — render hops from path_json (what this observation reported).
@@ -3906,7 +3926,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 96ffe2d56..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,6 +102,12 @@ const ctx = {
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
btoa: (s) => Buffer.from(s, 'binary').toString('base64'),
registerPage: noop,
+ // 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
@@ -89,11 +121,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 +188,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);
+ assert.strictEqual(senderBadge({ senderPathHashSize: 0 }), '');
+ assert.strictEqual(senderBadge({ senderPathHashSize: '
' }), '');
+});
+
+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',
@@ -212,6 +259,7 @@ test('delta cache absorbs later evidence for the same packet without re-decrypti
packets: [{
hash: 'same-packet',
first_seen: timestamp,
+ 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' },
}],
@@ -221,9 +269,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');
});
}
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 ===');
{