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' }), ''); +}); + +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 ==='); {