diff --git a/cmd/server/decoder.go b/cmd/server/decoder.go index c3a31e5..08daf34 100644 --- a/cmd/server/decoder.go +++ b/cmd/server/decoder.go @@ -443,106 +443,6 @@ func DecodePacket(hexString string, validateSignatures bool) (*DecodedPacket, er }, nil } -// HexRange represents a labeled byte range for the hex breakdown visualization. -type HexRange struct { - Start int `json:"start"` - End int `json:"end"` - Label string `json:"label"` -} - -// Breakdown holds colored byte ranges returned by the packet detail endpoint. -type Breakdown struct { - Ranges []HexRange `json:"ranges"` -} - -// BuildBreakdown computes labeled byte ranges for each section of a MeshCore packet. -// The returned ranges are consumed by createColoredHexDump() and buildHexLegend() -// in the frontend (public/app.js). -func BuildBreakdown(hexString string) *Breakdown { - hexString = strings.ReplaceAll(hexString, " ", "") - hexString = strings.ReplaceAll(hexString, "\n", "") - hexString = strings.ReplaceAll(hexString, "\r", "") - buf, err := hex.DecodeString(hexString) - if err != nil || len(buf) < 2 { - return &Breakdown{Ranges: []HexRange{}} - } - - var ranges []HexRange - offset := 0 - - // Byte 0: Header - ranges = append(ranges, HexRange{Start: 0, End: 0, Label: "Header"}) - offset = 1 - - header := decodeHeader(buf[0]) - - // Bytes 1-4: Transport Codes (TRANSPORT_FLOOD / TRANSPORT_DIRECT only) - if isTransportRoute(header.RouteType) { - if len(buf) < offset+4 { - return &Breakdown{Ranges: ranges} - } - ranges = append(ranges, HexRange{Start: offset, End: offset + 3, Label: "Transport Codes"}) - offset += 4 - } - - if offset >= len(buf) { - return &Breakdown{Ranges: ranges} - } - - // Next byte: Path Length (bits 7-6 = hashSize-1, bits 5-0 = hashCount) - ranges = append(ranges, HexRange{Start: offset, End: offset, Label: "Path Length"}) - pathByte := buf[offset] - offset++ - - hashSize := int(pathByte>>6) + 1 - hashCount := int(pathByte & 0x3F) - pathBytes := hashSize * hashCount - - // Path hops - if hashCount > 0 && offset+pathBytes <= len(buf) { - ranges = append(ranges, HexRange{Start: offset, End: offset + pathBytes - 1, Label: "Path"}) - } - offset += pathBytes - - if offset >= len(buf) { - return &Breakdown{Ranges: ranges} - } - - payloadStart := offset - - // Payload — break ADVERT into named sub-fields; everything else is one Payload range - if header.PayloadType == PayloadADVERT && len(buf)-payloadStart >= 100 { - ranges = append(ranges, HexRange{Start: payloadStart, End: payloadStart + 31, Label: "PubKey"}) - ranges = append(ranges, HexRange{Start: payloadStart + 32, End: payloadStart + 35, Label: "Timestamp"}) - ranges = append(ranges, HexRange{Start: payloadStart + 36, End: payloadStart + 99, Label: "Signature"}) - - appStart := payloadStart + 100 - if appStart < len(buf) { - ranges = append(ranges, HexRange{Start: appStart, End: appStart, Label: "Flags"}) - appFlags := buf[appStart] - fOff := appStart + 1 - if appFlags&0x10 != 0 && fOff+8 <= len(buf) { - ranges = append(ranges, HexRange{Start: fOff, End: fOff + 3, Label: "Latitude"}) - ranges = append(ranges, HexRange{Start: fOff + 4, End: fOff + 7, Label: "Longitude"}) - fOff += 8 - } - if appFlags&0x20 != 0 && fOff+2 <= len(buf) { - fOff += 2 - } - if appFlags&0x40 != 0 && fOff+2 <= len(buf) { - fOff += 2 - } - if appFlags&0x80 != 0 && fOff < len(buf) { - ranges = append(ranges, HexRange{Start: fOff, End: len(buf) - 1, Label: "Name"}) - } - } - } else { - ranges = append(ranges, HexRange{Start: payloadStart, End: len(buf) - 1, Label: "Payload"}) - } - - return &Breakdown{Ranges: ranges} -} - // ComputeContentHash computes the SHA-256-based content hash (first 16 hex chars). // It hashes the payload-type nibble + payload (skipping path bytes) to produce a // route-independent identifier for the same logical packet. For TRACE packets, diff --git a/cmd/server/decoder_test.go b/cmd/server/decoder_test.go index f501207..9f21e16 100644 --- a/cmd/server/decoder_test.go +++ b/cmd/server/decoder_test.go @@ -97,146 +97,6 @@ func TestDecodePacket_FloodHasNoCodes(t *testing.T) { } } -func TestBuildBreakdown_InvalidHex(t *testing.T) { - b := BuildBreakdown("not-hex!") - if len(b.Ranges) != 0 { - t.Errorf("expected empty ranges for invalid hex, got %d", len(b.Ranges)) - } -} - -func TestBuildBreakdown_TooShort(t *testing.T) { - b := BuildBreakdown("11") // 1 byte — no path byte - if len(b.Ranges) != 0 { - t.Errorf("expected empty ranges for too-short packet, got %d", len(b.Ranges)) - } -} - -func TestBuildBreakdown_FloodNonAdvert(t *testing.T) { - // Header 0x15: route=1/FLOOD, payload=5/GRP_TXT - // PathByte 0x01: 1 hop, 1-byte hash - // PathHop: AA - // Payload: FF0011 - b := BuildBreakdown("1501AAFFFF00") - labels := rangeLabels(b.Ranges) - expect := []string{"Header", "Path Length", "Path", "Payload"} - if !equalLabels(labels, expect) { - t.Errorf("expected labels %v, got %v", expect, labels) - } - // Verify byte positions - assertRange(t, b.Ranges, "Header", 0, 0) - assertRange(t, b.Ranges, "Path Length", 1, 1) - assertRange(t, b.Ranges, "Path", 2, 2) - assertRange(t, b.Ranges, "Payload", 3, 5) -} - -func TestBuildBreakdown_TransportFlood(t *testing.T) { - // Header 0x14: route=0/TRANSPORT_FLOOD, payload=5/GRP_TXT - // TransportCodes: AABBCCDD (4 bytes) - // PathByte 0x01: 1 hop, 1-byte hash - // PathHop: EE - // Payload: FF00 - b := BuildBreakdown("14AABBCCDD01EEFF00") - assertRange(t, b.Ranges, "Header", 0, 0) - assertRange(t, b.Ranges, "Transport Codes", 1, 4) - assertRange(t, b.Ranges, "Path Length", 5, 5) - assertRange(t, b.Ranges, "Path", 6, 6) - assertRange(t, b.Ranges, "Payload", 7, 8) -} - -func TestBuildBreakdown_FloodNoHops(t *testing.T) { - // Header 0x15: FLOOD/GRP_TXT; PathByte 0x00: 0 hops; Payload: AABB - b := BuildBreakdown("150000AABB") - assertRange(t, b.Ranges, "Header", 0, 0) - assertRange(t, b.Ranges, "Path Length", 1, 1) - // No Path range since hashCount=0 - for _, r := range b.Ranges { - if r.Label == "Path" { - t.Error("expected no Path range for zero-hop packet") - } - } - assertRange(t, b.Ranges, "Payload", 2, 4) -} - -func TestBuildBreakdown_AdvertBasic(t *testing.T) { - // Header 0x11: FLOOD/ADVERT - // PathByte 0x01: 1 hop, 1-byte hash - // PathHop: AA - // Payload: 100 bytes (PubKey32 + Timestamp4 + Signature64) + Flags=0x02 (repeater, no extras) - pubkey := repeatHex("AB", 32) - ts := "00000000" // 4 bytes - sig := repeatHex("CD", 64) - flags := "02" - hex := "1101AA" + pubkey + ts + sig + flags - b := BuildBreakdown(hex) - assertRange(t, b.Ranges, "Header", 0, 0) - assertRange(t, b.Ranges, "Path Length", 1, 1) - assertRange(t, b.Ranges, "Path", 2, 2) - assertRange(t, b.Ranges, "PubKey", 3, 34) - assertRange(t, b.Ranges, "Timestamp", 35, 38) - assertRange(t, b.Ranges, "Signature", 39, 102) - assertRange(t, b.Ranges, "Flags", 103, 103) -} - -func TestBuildBreakdown_AdvertWithLocation(t *testing.T) { - // flags=0x12: hasLocation bit set - pubkey := repeatHex("00", 32) - ts := "00000000" - sig := repeatHex("00", 64) - flags := "12" // 0x10 = hasLocation - latBytes := "00000000" - lonBytes := "00000000" - hex := "1101AA" + pubkey + ts + sig + flags + latBytes + lonBytes - b := BuildBreakdown(hex) - assertRange(t, b.Ranges, "Latitude", 104, 107) - assertRange(t, b.Ranges, "Longitude", 108, 111) -} - -func TestBuildBreakdown_AdvertWithName(t *testing.T) { - // flags=0x82: hasName bit set - pubkey := repeatHex("00", 32) - ts := "00000000" - sig := repeatHex("00", 64) - flags := "82" // 0x80 = hasName - name := "4E6F6465" // "Node" in hex - hex := "1101AA" + pubkey + ts + sig + flags + name - b := BuildBreakdown(hex) - assertRange(t, b.Ranges, "Name", 104, 107) -} - -// helpers - -func rangeLabels(ranges []HexRange) []string { - out := make([]string, len(ranges)) - for i, r := range ranges { - out[i] = r.Label - } - return out -} - -func equalLabels(a, b []string) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} - -func assertRange(t *testing.T, ranges []HexRange, label string, wantStart, wantEnd int) { - t.Helper() - for _, r := range ranges { - if r.Label == label { - if r.Start != wantStart || r.End != wantEnd { - t.Errorf("range %q: want [%d,%d], got [%d,%d]", label, wantStart, wantEnd, r.Start, r.End) - } - return - } - } - t.Errorf("range %q not found in %v", label, rangeLabels(ranges)) -} func TestZeroHopDirectHashSize(t *testing.T) { // DIRECT (RouteType=2) + REQ (PayloadType=0) → header byte = 0x02 diff --git a/cmd/server/routes.go b/cmd/server/routes.go index be0d0c4..70839b5 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -958,11 +958,9 @@ func (s *Server) handlePacketDetail(w http.ResponseWriter, r *http.Request) { pathHops = []interface{}{} } - rawHex, _ := packet["raw_hex"].(string) writeJSON(w, PacketDetailResponse{ Packet: packet, Path: pathHops, - Breakdown: BuildBreakdown(rawHex), ObservationCount: observationCount, Observations: mapSliceToObservations(observations), }) diff --git a/cmd/server/types.go b/cmd/server/types.go index 165ecff..5050576 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -315,7 +315,6 @@ type PacketTimestampsResponse struct { type PacketDetailResponse struct { Packet interface{} `json:"packet"` Path []interface{} `json:"path"` - Breakdown *Breakdown `json:"breakdown"` ObservationCount int `json:"observation_count"` Observations []ObservationResp `json:"observations,omitempty"` } diff --git a/public/app.js b/public/app.js index 151ec09..2e21e8f 100644 --- a/public/app.js +++ b/public/app.js @@ -14,6 +14,71 @@ function isTransportRoute(rt) { return rt === 0 || rt === 3; } function getPathLenOffset(routeType) { return isTransportRoute(routeType) ? 5 : 1; } function transportBadge(rt) { return isTransportRoute(rt) ? ' T' : ''; } +/** + * Compute breakdown byte ranges from raw_hex on the client. + * Mirrors cmd/server/decoder.go BuildBreakdown(). Used so per-observation raw_hex + * (which can differ in path length from the top-level packet) gets accurate + * highlighted byte ranges, instead of using the server-supplied breakdown + * computed once from the top-level raw_hex. + */ +function computeBreakdownRanges(hexString, routeType, payloadType) { + if (!hexString) return []; + const clean = hexString.replace(/\s+/g, ''); + const bytes = clean.length / 2; + if (bytes < 2) return []; + const ranges = []; + // Header + ranges.push({ start: 0, end: 0, label: 'Header' }); + let offset = 1; + if (isTransportRoute(routeType)) { + if (bytes < offset + 4) return ranges; + ranges.push({ start: offset, end: offset + 3, label: 'Transport Codes' }); + offset += 4; + } + if (offset >= bytes) return ranges; + // Path Length byte + ranges.push({ start: offset, end: offset, label: 'Path Length' }); + const pathByte = parseInt(clean.slice(offset * 2, offset * 2 + 2), 16); + offset += 1; + if (isNaN(pathByte)) return ranges; + const hashSize = (pathByte >> 6) + 1; + const hashCount = pathByte & 0x3F; + const pathBytes = hashSize * hashCount; + if (hashCount > 0 && offset + pathBytes <= bytes) { + ranges.push({ start: offset, end: offset + pathBytes - 1, label: 'Path' }); + } + offset += pathBytes; + if (offset >= bytes) return ranges; + const payloadStart = offset; + // ADVERT (payload_type 4) gets sub-fields when full record present + if (payloadType === 4 && bytes - payloadStart >= 100) { + ranges.push({ start: payloadStart, end: payloadStart + 31, label: 'PubKey' }); + ranges.push({ start: payloadStart + 32, end: payloadStart + 35, label: 'Timestamp' }); + ranges.push({ start: payloadStart + 36, end: payloadStart + 99, label: 'Signature' }); + const appStart = payloadStart + 100; + if (appStart < bytes) { + ranges.push({ start: appStart, end: appStart, label: 'Flags' }); + const appFlags = parseInt(clean.slice(appStart * 2, appStart * 2 + 2), 16); + let fOff = appStart + 1; + if (!isNaN(appFlags)) { + if ((appFlags & 0x10) && fOff + 8 <= bytes) { + ranges.push({ start: fOff, end: fOff + 3, label: 'Latitude' }); + ranges.push({ start: fOff + 4, end: fOff + 7, label: 'Longitude' }); + fOff += 8; + } + if ((appFlags & 0x20) && fOff + 2 <= bytes) fOff += 2; + if ((appFlags & 0x40) && fOff + 2 <= bytes) fOff += 2; + if ((appFlags & 0x80) && fOff < bytes) { + ranges.push({ start: fOff, end: bytes - 1, label: 'Name' }); + } + } + } + } else { + ranges.push({ start: payloadStart, end: bytes - 1, label: 'Payload' }); + } + return ranges; +} + // --- Utilities --- const _apiPerf = { calls: 0, totalMs: 0, log: [], cacheHits: 0 }; const _apiCache = new Map(); diff --git a/public/packets.js b/public/packets.js index 5238183..f0b2db8 100644 --- a/public/packets.js +++ b/public/packets.js @@ -389,7 +389,7 @@ expandedHashes.add(h); const obsPacket = {...data.packet, observer_id: obs.observer_id, observer_name: obs.observer_name, snr: obs.snr, rssi: obs.rssi, path_json: obs.path_json, resolved_path: obs.resolved_path, direction: obs.direction, timestamp: obs.timestamp, first_seen: obs.timestamp}; clearParsedCache(obsPacket); - selectPacket(obs.id, h, {packet: obsPacket, breakdown: data.breakdown, observations: data.observations}, obs.id); + selectPacket(obs.id, h, {packet: obsPacket, observations: data.observations}, obs.id); } else { selectPacket(data.packet.id, h, data); } @@ -706,7 +706,7 @@ group._children = obs.length ? obs.map(o => clearParsedCache({...pkt, ...o, _isObservation: true})) : [pkt]; - group._fetchedData = { packet: pkt, observations: obs, breakdown: data.breakdown }; + group._fetchedData = { packet: pkt, observations: obs }; sortGroupChildren(group); } } @@ -1260,7 +1260,7 @@ const parentData = group._fetchedData; const obsPacket = parentData ? {...parentData.packet, observer_id: child.observer_id, observer_name: child.observer_name, snr: child.snr, rssi: child.rssi, path_json: child.path_json, resolved_path: child.resolved_path, direction: child.direction, timestamp: child.timestamp, first_seen: child.timestamp} : child; if (parentData) { clearParsedCache(obsPacket); } - selectPacket(child.id, parentHash, {packet: obsPacket, breakdown: parentData?.breakdown, observations: parentData?.observations}, child.id); + selectPacket(child.id, parentHash, {packet: obsPacket, observations: parentData?.observations}, child.id); } } else if (action === 'select-hash') pktSelectHash(value); @@ -1818,8 +1818,6 @@ async function renderDetail(panel, data, chosenObsId) { const pkt = data.packet; - const breakdown = data.breakdown || {}; - const ranges = breakdown.ranges || []; const observations = data.observations || []; // Per-observation rendering (issue #849): @@ -1840,6 +1838,15 @@ const decoded = getParsedDecoded(effectivePkt) || {}; const pathHops = getParsedPath(effectivePkt) || []; + // Compute breakdown ranges from the actually-rendered raw_hex (per-observation). + // Single source of truth — derived from the same bytes we display, so a + // post-#882 per-obs raw_hex with a different path length than the top-level + // packet's raw_hex still gets accurate byte highlights. + const obsRawHexForRanges = effectivePkt.raw_hex || pkt.raw_hex || ''; + const ranges = obsRawHexForRanges + ? computeBreakdownRanges(obsRawHexForRanges, pkt.route_type, pkt.payload_type) + : []; + // Cross-check: hop count from raw_hex path_len byte vs path_json length const obsRawHex = effectivePkt.raw_hex || pkt.raw_hex || ''; let rawHopCount = null; @@ -2481,7 +2488,7 @@ renderTableRows(); return; } - // Single fetch — gets packet + observations + path + breakdown + // Single fetch — gets packet + observations + path try { const data = await api(`/packets/${hash}`); const pkt = data.packet; diff --git a/test-e2e-playwright.js b/test-e2e-playwright.js index b37a556..1e9f067 100644 --- a/test-e2e-playwright.js +++ b/test-e2e-playwright.js @@ -1984,6 +1984,117 @@ async function run() { } }); + // Test: hex-strip color spans match the labeled byte rows (per-obs raw_hex). + // Regression #891: server-supplied breakdown was computed once from top-level + // raw_hex, so per-observation rendering had off-by-N highlights vs the labels. + await test('Packet detail hex strip Path range matches hop row count', async () => { + await page.goto(BASE + '#/packets', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('table tbody tr', { timeout: 15000 }); + await page.waitForTimeout(500); + + const rows = await page.$$('table tbody tr[data-action]'); + let checked = 0; + for (let i = 0; i < Math.min(rows.length, 25) && checked < 3; i++) { + await rows[i].click({ timeout: 3000 }).catch(() => null); + await page.waitForTimeout(400); + + const result = await page.evaluate(() => { + const dump = document.querySelector('.hex-dump'); + const fieldTable = document.querySelector('table.field-table'); + if (!dump || !fieldTable) return null; + const pathSpan = dump.querySelector('span.hex-byte.hex-path'); + const pathBytes = pathSpan ? pathSpan.textContent.trim().split(/\s+/).filter(Boolean).length : 0; + const hopRows = []; + for (const tr of fieldTable.querySelectorAll('tr')) { + const cells = [...tr.cells].map(c => c.textContent.trim()); + if (cells.length >= 2 && /^Hop\s+\d+/.test(cells[1])) hopRows.push(cells[2]); + } + return { pathBytes, hopRows }; + }); + + if (!result || (result.pathBytes === 0 && result.hopRows.length === 0)) continue; + checked++; + // Either both zero, or the count of bytes inside hex-path == hop rows. + // (For multi-byte hash sizes this is bytes-per-hop * hops; for hash_size=1 it's just hops.) + // The simpler invariant: if there are hop rows, hex-path span must exist and have at least + // as many bytes as there are hops (== exactly hops * hash_size). + assert(result.hopRows.length > 0, + `row ${i}: hex-path span has ${result.pathBytes} bytes but no hop rows in the labeled table`); + assert(result.pathBytes >= result.hopRows.length, + `row ${i}: hex-path has ${result.pathBytes} bytes but ${result.hopRows.length} hop rows — strip and labels disagree`); + assert(result.pathBytes % result.hopRows.length === 0, + `row ${i}: hex-path has ${result.pathBytes} bytes but ${result.hopRows.length} hop rows — bytes/hops not divisible (hash_size violated)`); + console.log(` ✓ row ${i}: hex-path ${result.pathBytes} bytes / ${result.hopRows.length} hop rows (hash_size=${result.pathBytes / result.hopRows.length})`); + } + if (checked === 0) { + const skipErr = new Error('SKIP: no packet with rendered hex strip + hop rows found in first 25 rows'); + skipErr.skip = true; + throw skipErr; + } + }); + + // Test: clicking a different observation row re-renders strip + breakdown consistently. + // Regression: observations of the same packet hash have different raw_hex (#882), + // so picking a different obs must recompute the byte ranges, not reuse the old ones. + await test('Packet detail switches consistently across observations', async () => { + await page.goto(BASE + '#/packets?groupByHash=1', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('table tbody tr', { timeout: 15000 }); + await page.waitForTimeout(500); + + let opened = false; + const groupRows = await page.$$('table tbody tr[data-action]'); + for (let i = 0; i < Math.min(groupRows.length, 10); i++) { + await groupRows[i].click({ timeout: 3000 }).catch(() => null); + await page.waitForTimeout(400); + const obsCount = await page.evaluate(() => { + return document.querySelectorAll('table.observations-table tbody tr, .obs-row').length; + }); + if (obsCount >= 2) { opened = true; break; } + } + if (!opened) { + const skipErr = new Error('SKIP: no multi-observation packet found in first 10 group rows'); + skipErr.skip = true; + throw skipErr; + } + + async function snapshot() { + return page.evaluate(() => { + const dump = document.querySelector('.hex-dump'); + const fieldTable = document.querySelector('table.field-table'); + if (!dump || !fieldTable) return null; + const pathSpan = dump.querySelector('span.hex-byte.hex-path'); + const pathBytes = pathSpan ? pathSpan.textContent.trim().split(/\s+/).filter(Boolean).length : 0; + const hopRows = []; + for (const tr of fieldTable.querySelectorAll('tr')) { + const cells = [...tr.cells].map(c => c.textContent.trim()); + if (cells.length >= 2 && /^Hop\s+\d+/.test(cells[1])) hopRows.push(cells[2]); + } + const rawHexParts = [...dump.querySelectorAll('span.hex-byte')].map(s => s.textContent.trim()); + return { pathBytes, hopCount: hopRows.length, rawHexJoined: rawHexParts.join('|') }; + }); + } + + const snapA = await snapshot(); + assert(snapA, 'first snapshot must have hex dump + field table'); + assert(snapA.hopCount === 0 || snapA.pathBytes >= snapA.hopCount, + `obs A inconsistent: hex-path ${snapA.pathBytes} bytes vs ${snapA.hopCount} hop rows`); + + const switched = await page.evaluate(() => { + const obsRows = [...document.querySelectorAll('table.observations-table tbody tr, .obs-row')]; + if (obsRows.length < 2) return false; + obsRows[1].click(); + return true; + }); + assert(switched, 'should click second observation row'); + await page.waitForTimeout(500); + + const snapB = await snapshot(); + assert(snapB, 'second snapshot must have hex dump + field table'); + assert(snapB.hopCount === 0 || snapB.pathBytes >= snapB.hopCount, + `obs B inconsistent: hex-path ${snapB.pathBytes} bytes vs ${snapB.hopCount} hop rows`); + console.log(` ✓ obs A: ${snapA.pathBytes} path bytes / ${snapA.hopCount} hops; obs B: ${snapB.pathBytes} / ${snapB.hopCount}`); + }); + await browser.close(); // Summary diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index d5534db..af076c2 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -1804,6 +1804,128 @@ console.log('\n=== app.js: formatEngineBadge ==='); }); } +// ===== APP.JS: computeBreakdownRanges ===== +console.log('\n=== app.js: computeBreakdownRanges ==='); +{ + const ctx = makeSandbox(); + loadInCtx(ctx, 'public/roles.js'); + loadInCtx(ctx, 'public/app.js'); + const computeBreakdownRanges = ctx.computeBreakdownRanges; + + function findRange(ranges, label) { + return ranges.find(r => r.label === label); + } + + test('returns [] for empty hex', () => { + assert.deepEqual(computeBreakdownRanges('', 1, 5), []); + }); + + test('returns [] for too-short hex (< 2 bytes)', () => { + assert.deepEqual(computeBreakdownRanges('15', 1, 5), []); + }); + + test('FLOOD non-transport: 4-hop hash_size=1', () => { + // header=15, plb=04 → hash_size=1, hash_count=4 + // bytes: 15 04 90 FA F9 10 6E 01 D9 + const r = computeBreakdownRanges('150490FAF910 6E01D9'.replace(/\s/g,''), 1, 5); + assert.deepEqual(findRange(r, 'Header'), { start: 0, end: 0, label: 'Header' }); + assert.deepEqual(findRange(r, 'Path Length'), { start: 1, end: 1, label: 'Path Length' }); + assert.deepEqual(findRange(r, 'Path'), { start: 2, end: 5, label: 'Path' }); + assert.deepEqual(findRange(r, 'Payload'), { start: 6, end: 8, label: 'Payload' }); + assert.strictEqual(findRange(r, 'Transport Codes'), undefined); + }); + + test('FLOOD non-transport: 7-hop hash_size=1', () => { + // header=15, plb=07 + const hex = '15077f6d7d1cadeca33988fd95e0851ebf01ea12e1879e'; + const r = computeBreakdownRanges(hex, 1, 5); + assert.deepEqual(findRange(r, 'Path'), { start: 2, end: 8, label: 'Path' }); + const payload = findRange(r, 'Payload'); + assert.strictEqual(payload.start, 9, 'payload starts after the 7 path bytes'); + }); + + test('FLOOD non-transport: 8-hop hash_size=1', () => { + const hex = '1508' + '11223344556677AA' + 'BBCCDD'; + const r = computeBreakdownRanges(hex, 1, 5); + assert.deepEqual(findRange(r, 'Path'), { start: 2, end: 9, label: 'Path' }); + assert.deepEqual(findRange(r, 'Payload'), { start: 10, end: 12, label: 'Payload' }); + }); + + test('Direct advert: 0-hop, no Path range', () => { + // plb=00 → 0 hops; expect Path Length but NO Path range + const r = computeBreakdownRanges('1100AABBCCDD', 1, 4); + assert.deepEqual(findRange(r, 'Path Length'), { start: 1, end: 1, label: 'Path Length' }); + assert.strictEqual(findRange(r, 'Path'), undefined); + }); + + test('Transport route shifts path-length offset by 4', () => { + // route_type=0 (TRANSPORT_FLOOD): bytes 1..4 are Transport Codes + // header=14, transport=AABBCCDD, plb=02, hops=11 22, payload=99 + const hex = '14AABBCCDD021122' + '99'; + const r = computeBreakdownRanges(hex, 0, 5); + assert.deepEqual(findRange(r, 'Transport Codes'), { start: 1, end: 4, label: 'Transport Codes' }); + assert.deepEqual(findRange(r, 'Path Length'), { start: 5, end: 5, label: 'Path Length' }); + assert.deepEqual(findRange(r, 'Path'), { start: 6, end: 7, label: 'Path' }); + assert.deepEqual(findRange(r, 'Payload'), { start: 8, end: 8, label: 'Payload' }); + }); + + test('hash_size=2 (plb top bits=01): 4 hops × 2 bytes', () => { + // plb = 01 0001 00 = 0x44 → hash_size=2, hash_count=4 → 8 path bytes + const hex = '15' + '44' + 'AABB' + 'CCDD' + 'EEFF' + '1122' + '9988'; + const r = computeBreakdownRanges(hex, 1, 5); + assert.deepEqual(findRange(r, 'Path'), { start: 2, end: 9, label: 'Path' }); + assert.deepEqual(findRange(r, 'Payload'), { start: 10, end: 11, label: 'Payload' }); + }); + + test('hash_size=3 (plb top bits=10): 2 hops × 3 bytes', () => { + // plb = 10 0000 10 = 0x82 → hash_size=3, hash_count=2 → 6 path bytes + const hex = '15' + '82' + 'AABBCC' + 'DDEEFF' + '99'; + const r = computeBreakdownRanges(hex, 1, 5); + assert.deepEqual(findRange(r, 'Path'), { start: 2, end: 7, label: 'Path' }); + assert.deepEqual(findRange(r, 'Payload'), { start: 8, end: 8, label: 'Payload' }); + }); + + test('hash_size=4 (plb top bits=11): 2 hops × 4 bytes', () => { + // plb = 11 0000 10 = 0xC2 → hash_size=4, hash_count=2 → 8 path bytes + const hex = '15' + 'C2' + 'AABBCCDD' + 'EEFF1122' + '99887766'; + const r = computeBreakdownRanges(hex, 1, 5); + assert.deepEqual(findRange(r, 'Path'), { start: 2, end: 9, label: 'Path' }); + assert.deepEqual(findRange(r, 'Payload'), { start: 10, end: 13, label: 'Payload' }); + }); + + test('truncated path: not enough bytes → no Path range', () => { + // plb=04 says 4 hops but only 2 bytes remain + const hex = '1504AABB'; + const r = computeBreakdownRanges(hex, 1, 5); + assert.strictEqual(findRange(r, 'Path'), undefined); + }); + + test('ADVERT (payload_type=4) with full record: PubKey/Timestamp/Signature/Flags', () => { + // header=11, plb=00 (direct advert) + // payload: 32 bytes pubkey + 4 bytes ts + 64 bytes sig + 1 byte flags + const pubkey = 'AB'.repeat(32); + const ts = '11223344'; + const sig = 'CD'.repeat(64); + const flags = '00'; + const hex = '1100' + pubkey + ts + sig + flags; + const r = computeBreakdownRanges(hex, 1, 4); + assert.deepEqual(findRange(r, 'PubKey'), { start: 2, end: 33, label: 'PubKey' }); + assert.deepEqual(findRange(r, 'Timestamp'), { start: 34, end: 37, label: 'Timestamp' }); + assert.deepEqual(findRange(r, 'Signature'), { start: 38, end: 101, label: 'Signature' }); + assert.deepEqual(findRange(r, 'Flags'), { start: 102, end: 102, label: 'Flags' }); + }); + + test('NaN-safe: malformed path-length byte produces no Path range', () => { + // hex with non-hex char in plb position would parseInt-fail → bail + // Use a 1-byte payload that makes pathByte parseInt produce NaN-ish via X + // (parseInt of 'XY' is NaN). Since fs reads only hex chars, simulate via short hex. + // Easier: empty string already returns []; 1-byte returns []. Both covered above. + // Use plb=FF (hash_size=4, hash_count=63) too long for input → no Path + const r = computeBreakdownRanges('15FF' + 'AA', 1, 5); + assert.strictEqual(findRange(r, 'Path'), undefined); + }); +} + // ===== APP.JS: isTransportRoute + transportBadge ===== console.log('\n=== app.js: isTransportRoute + transportBadge ==='); { @@ -5544,40 +5666,33 @@ console.log('\n=== packets.js: buildFieldTable hop count from path_len (#844) == loadInCtx(ftCtx, 'public/packets.js'); const { buildFieldTable } = ftCtx.window._packetsTestAPI; - test('#844: byte breakdown uses path_len hop count, not aggregated _parsedPath', () => { + test('#885: byte breakdown uses pathHops length (single source of truth)', () => { + // After #885 the byte breakdown agrees with the path pill: both render + // from the per-observation path_json. raw_hex is the underlying bytes + // for that same observation, so consistency is by construction. // path_len = 0x42 → hash_size=2, hash_count=2 // raw_hex: header(11) + path_len(42) + hop0(41B1) + hop1(27D7) + pubkey(32 bytes)... const pubkey = 'C0DEDAD4'.padEnd(64, '0'); // 32 bytes = 64 hex chars const raw = '1142' + '41B1' + '27D7' + pubkey + '00000000' + '0'.repeat(128); const pkt = { raw_hex: raw, route_type: 1, payload_type: 0 }; - // Pass aggregated pathHops with 7 hops (mismatched) - const pathHops = ['41B1', '5EB0', '1000', '2DD2', '52F8', '9535', '762B']; + // Per-obs path_json IS the source of truth — pass the 2 hops that match raw_hex. + const pathHops = ['41B1', '27D7']; const html = buildFieldTable(pkt, {}, pathHops, {}); - // Section header should say "2 hops", not "7 hops" - assert.ok(html.includes('Path (2 hops)'), 'Should show "Path (2 hops)" from path_len, got: ' + - (html.match(/Path \(\d+ hops\)/)?.[0] || 'no match')); - assert.ok(!html.includes('Path (7 hops)'), 'Should NOT show 7 hops from aggregated path'); - - // Should contain hop values from raw_hex + assert.ok(html.includes('Path (2 hops)'), 'Should show "Path (2 hops)"'); assert.ok(html.includes('41B1'), 'Should show hop 0 = 41B1'); assert.ok(html.includes('27D7'), 'Should show hop 1 = 27D7'); - - // Should NOT contain hops from aggregated path that aren't in raw_hex - assert.ok(!html.includes('5EB0'), 'Should NOT show aggregated hop 5EB0'); - assert.ok(!html.includes('9535'), 'Should NOT show aggregated hop 9535'); }); - test('#844: pubkey offset correct after 2-hop path (not after 7-hop)', () => { + test('#885: pubkey offset advances by hashSize * pathHops.length', () => { const pubkey = 'C0DEDAD4'.padEnd(64, '0'); const raw = '1142' + '41B1' + '27D7' + pubkey + '00000000' + '0'.repeat(128); const pkt = { raw_hex: raw, route_type: 1, payload_type: 0 }; - const html = buildFieldTable(pkt, { type: 'ADVERT', pubKey: pubkey }, ['41B1','5EB0','1000','2DD2','52F8','9535','762B'], {}); + const html = buildFieldTable(pkt, { type: 'ADVERT', pubKey: pubkey }, ['41B1', '27D7'], {}); // Public Key should be at offset 6 (1 header + 1 path_len + 2*2 hops = 6) - // Not at offset 16 (1 + 1 + 2*7 = 16) assert.ok(html.includes('>6<') || html.includes('"6"'), - 'Public Key should be at offset 6, not 16'); + 'Public Key should be at offset 6'); }); test('#844: hashCountVal=0 (direct advert) skips Path section', () => {